ACTF2023

本文最后更新于:7 个月前

MyGO’s Live!!!!!

启动题目环境:

根路由

1
2
3
4
5
6
7
8
9
10
11
app.get('/', (req, res) => {
fs.readFile(__dirname + '/public/index.html', 'utf8', (err, data) => {
if (err) {
console.error(err);
res.status(500).send('Internal Server Error');
} else {
// Send the HTML content
res.send(data);
}
})
}

/checker路由:

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
app.get('/checker', (req, res) => {
let url = req.query.url;

if (url) {
if (url.length > 60) {
res.send("我喜欢你");
return;
}
url = [...url].map(escaped).join("");
console.log(url);

let host;
let port;
if (url.includes(":")) {
const parts = url.split(":");
host = parts[0];
port = parts.slice(1).join(":");
} else {
host = url;
}
let command = "";
// console.log(host);
// console.log(port);

if (port) {
if (isNaN(parseInt(port))) {
res.send("我喜欢你");
return;
}
command = ["nmap", "-p", port, host].join(" "); // Construct the shell command
} else {
command = ["nmap", "-p", "80", host].join(" ");
}

var fdout = fs.openSync('stdout.log', 'a');
var fderr = fs.openSync('stderr.log', 'a');
nmap = spawn("bash", ["-c", command], {stdio: [0,fdout,fderr] } );

nmap.on('exit', function (code) {
console.log('child process exited with code ' + code.toString());
if (code !== 0) {
let data = fs.readFileSync('stderr.log');
console.error(`Error executing command: ${data}`);
res.send(`Error executing command!!! ${data}`);
} else {
let data = fs.readFileSync('stdout.log');
console.error(`Ok: ${data}`);
res.send(`${data}`);
}
});
} else {
res.send('No parameter provided.');
}
});

逐行解释:

url变量从请求的url参数获值,如果url长度大于60渲染”我喜欢你”,

该行代码将url字符串分隔为字符数组逐个参与escaped()函数,最后再连接起来成为一个新的字符串

1
url = [...url].map(escaped).join("");

escaped()函数中一共过滤了以下字符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
' '//空格
'$'
'\'
'`'//反引号
'"'
'|'
'&'
';'
'<'
'>'
'('
')'
'''
'\n'
'*'

下面判断url中是否存在:,对host和port进行赋值

然后拼接构造一个nmap的命令,将各个字符串用空格分隔开拼接起来:

1
2
3
4
5
6
7
8
9
if (port) {
if (isNaN(parseInt(port))) {
res.send("我喜欢你");
return;
}
command = ["nmap", "-p", port, host].join(" "); // Construct the shell command
} else {
command = ["nmap", "-p", "80", host].join(" ");
}

起一个bash进程执行nmap命令:

1
nmap = spawn("bash", ["-c", command], {stdio: [0,fdout,fderr] } );

对nmap对象进行处理,发现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var fdout = fs.openSync('stdout.log', 'a');
var fderr = fs.openSync('stderr.log', 'a');
nmap = spawn("bash", ["-c", command], {stdio: [0,fdout,fderr] } );
nmap.on('exit', function (code) {
console.log('child process exited with code ' + code.toString());
if (code !== 0) {
let data = fs.readFileSync('stderr.log');
console.error(`Error executing command: ${data}`);
res.send(`Error executing command!!! ${data}`);
} else {
let data = fs.readFileSync('stdout.log');
console.error(`Ok: ${data}`);
res.send(`${data}`);
}
});

将报错结果存入stderr.log,将进程的运行结果存入stdout.log,(而不是过程中的)

那么如何获得nmap的运行结果呢?翻阅namp的使用手册,总结出了两个可以用的参数:

-iL:从外部打开文件,读取里面的ip地址进行扫描;

-oN:将执行结果存入外部文件中,可以将结果写入静态目录直接读取

使用-iL时发现如果打开的文件中不存在合法ip,返回的错误中就会存在文件的内容:

接下来只需要将这个报错的结果存到外部文件直接访问就好了,但是现在问题来了,上面被过滤了那么多的字符该怎么绕过

观察我们期望传入的字符,空格被ban了是最大的问题,曾经在另一篇文章中总结过相关的trick,这里$都被ban了,那么就试试{,},Linux中可用{,}来无空格执行命令:

测试成功:

最后一个问题就是,在Dockerfile对flag的处理中发现flag被拼接上了-加上16个随机字符,思路是正则绕过,但是*被ban了,但是知道位数,可以用?来匹配:

最后将结果写入stdout.log:

payload:

1
http://192.168.64.1:36002/checker?url={-iL,/flag-????????????????,-oN,stdout.log}

Easylatex

一道xss

1
2
3
4
5
6
7
8
9
10
11
12
app.post('/login', (req, res) =>   {
let { username, password } = req.body

if (md5(username) != password) {
res.render('login', { msg: 'login failed' })
return
}

let token = sign({ username, isVip: false })
res.cookie('token', token)
res.redirect('/')
})

查看路由以用户名的md5为密码进行登录,登陆成功后给Cookie中的token设置为非vip

preview路由:

1
2
3
4
5
6
7
8
9
10
11
12
app.get('/preview', (req, res) => {
let { tex, theme } = req.query
if (!tex) {
tex = 'Today is \\today.'
}
const nonce = getNonce(16)
let base = 'https://cdn.jsdelivr.net/npm/latex.js/dist/'
if (theme) {
base = new URL(theme, `http://${req.headers.host}/theme/`) + '/'
}
res.render('preview.html', { tex, nonce, base })
})

base = new URL(theme, http://${req.headers.host}/theme/) + '/'这一段代码,theme我们是可控的

拼接完之后:

这道题XSS漏洞存在的关键是一个URL构造方法引起的,theme可控,我们可以把它指向自己的服务器:

服务器上装载恶意的js让网页来加载:

Story

先从程序入口看看,有这样几个重要的路由:

captcha路由,调用Capcha类的generate()方法来获取验证码来实现登录的验证

1
2
3
4
5
6
7
8
9
10
@app.route('/captcha')
def captcha():
gen = Captcha(200, 80)
buf, captcha_text = gen.generate()

session['captcha'] = captcha_text
return buf.getvalue(), 200, {
'Content-Type': 'image/png',
'Content-Length': str(len(buf.getvalue()))
}

vip路由,从请求体的json中获取captcha的键值并于generate_code()产生的验证码进行比较如果相同就将session中的vip字段设置为true:

1
2
3
4
5
6
7
@app.route('/vip', methods=['POST'])
def vip():
captcha = generate_code()
captcha_user = request.json.get('captcha', '')
if captcha == captcha_user:
session['vip'] = True
return render_template("home.html")

接着是这个write路由,经过了几层判断,当是vip的时候可以将写入的story存入session的story字段中,但是如果没通过waf的话就重置session:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@app.route('/write', methods=['POST', 'GET'])
def rename():
if request.method == "GET":
return redirect('/')

story = request.json.get('story', '')
if session.get('vip', ''):

if not minic_waf(story):
session['username'] = ""
session['vip'] = False
return jsonify({'status': 'error', 'message': 'no way~~~'})

session['story'] = story
return jsonify({'status': 'success', 'message': 'success'})

return jsonify({'status': 'error', 'message': 'Please become a VIP first.'}), 400

最后是这里的story路由,我们写入的story将被render_template_string()渲染出来,一个非常明显的ssti了,:

1
2
3
4
5
6
7
@app.route('/story', methods=['GET'])
def story():
story = session.get('story', '')
if story is not None and story != "":
tpl = open('templates/story.html', 'r').read()
return render_template_string(tpl % story)
return redirect("/")

接下来剩下两个待解决的问题:

  • 想写story就需要成为vip,如何成为vip?
  • 怎么绕过waf?

这里先从如何成为vip开始:

观察vip路由,跟进generate_code()方法,逻辑就是从待选字符中依次随机获取4个字符拼接在一起组成验证码:

1
2
3
def generate_code(length: int = 4):
characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
return ''.join(random.choice(characters) for _ in range(length))

使用的是random.choice()伪随机,采用的算法是固定的,当设置的种子是固定的时候,产生的随机结果就是固定的,

eg:

两次运行以下代码时,都是获取到’k’字符,但是当我把种子换成1145141,每次第一个产生的随机字符就是r,想伪造验证码,我们就得去找找种子在哪设置的

1
2
3
4
5
6
import random

random.seed("114514")
str = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
char = random.choice(str)
print(char)

最后一行可以看出是在captcha类的构造函数中播撒的seed,种子是_key_key的获取逻辑是在时间戳后面加上1到100的随机整数,因此实际上完全可能爆破获取_key来进行随机数预测的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#captcha.py
class Captcha:
lookup_table: t.List[int] = [int(i * 1.97) for i in range(256)]

def __init__(self, width: int = 160, height: int = 60, key: int = None, length: int = 4,
fonts: t.Optional[t.List[str]] = None, font_sizes: t.Optional[t.Tuple[int]] = None):
self._width = width
self._height = height
self._length = length
self._key = (key or int(time.time())) + random.randint(1, 100)
self._fonts = fonts or DEFAULT_FONTS
self._font_sizes = font_sizes or (42, 50, 56)
self._truefonts: t.List[FreeTypeFont] = []
random.seed(self._key)

去看看哪里实现了Captcha,其实只有一处,在captcha路由下,这下可以得到一个思路:通过不断地访问captcha路由来不断刷新种子,当: