Wane
3844 words
19 minutes
LA CTF 2025 Reversing Write-up
2025-02-09

LA CTF 2025 Write-up#

LA CTF 2024에 참여했던 적이 있었습니다. (LACTF 2024 - Write-Up)

당시에는 리버싱 올솔을 꿈에도 못 꿨는데, 지금은 이렇게 훨씬 어려운 난이도로 돌아온 2025년도의 LA CTF를 올솔하는 쾌거를 이루어내어 정말 뿌듯하고 기쁩니다.

Infobahn으로 참여하였습니다.

javascription#

처음에 보고 obfuscator.io를 이용해 난독화한 건 줄 알았지만, 아니였습니다. 그냥 Javascript를 읽고, 복호화하면 됩니다.

import base64
import urllib.parse

def decode_final(final: str) -> str:
    step4 = base64.b64decode(final).decode('utf-8')
    step3 = urllib.parse.unquote(step4)
    step2 = step3.replace("[OLD_DATA]", "Z")
    step1 = step2[::-1]
    flag = base64.b64decode(step1).decode('utf-8')
    return flag

target = "JTNEJTNEUWZsSlglNUJPTERfREFUQSU1RG85MWNzeFdZMzlWZXNwbmVwSjMlNUJPTERfREFUQSU1RGY5bWI3JTVCT0xEX0RBVEElNURHZGpGR2I="
recovered_flag = decode_final(target)
print(recovered_flag)

patricks-paraflag#

매우 간단합니다.

t="l_alcotsft{_tihne__ifnlfaign_igtoyt}"
print(t[::2]+t[1::2])

nine-solves#

매우 간단합니다. yi를 바탕으로 조건에 부합하는 6자리 문자열을 알아보면, AigyaP 또는 BigyaP 이 나오는데, 이것을 리모트에 입력하면 플래그가 나옵니다.

플래그는 BigyaP이 유일한 해답이라고 주장하는데, 이건 사실이 아닙니다.

the-eye#

현재 시간을 기준으로 한 srand 함수를 실행시켜 플래그가 포함된 암호문을 리모트에서 제공합니다. 이때 프로그램이 시작되는 시간을 유추할 수 있으니, 굉장히 제한된 범위 내에서 탐색을 수행할 수 있습니다.

import sys, time, ctypes
libc=ctypes.CDLL("libc.so.6")
libc.rand.restype=ctypes.c_int
libc.srand.argtypes=[ctypes.c_uint]
f=sys.stdin.readline().rstrip("\n")
n=len(f)
now=int(time.time())
for seed in range(now-60,now+61):
    libc.srand(seed)
    ops=[]
    for _ in range(22):
        for i in range(n-1,-1,-1):
            r=libc.rand()% (i+1)
            ops.append((i,r))
    a=list(f)
    for i,r in reversed(ops):
        a[i],a[r]=a[r],a[i]
    final = "".join(a)
    if "lactf" in final:
        print(seed, final)
        break

crypt-of-the-necropuzzler#

이걸 리버싱이라 불러야 하는지 애매합니다. Python 문제 파일 내에 있는 규칙을 이해하기에 직관적으로 쉽지 않습니다. 사실 이는 해당 게임을 성공하는 데에는 딱히 의미없는 행동입니다. 해당 문제는 NP-Hard 문제이기 때문입니다.

취약점은 보드의 크기가 7x7 정도로 매우 작아서, 완전 탐색 (2422^{42})이 엄청 무모한 짓은 아니라는 것입니다. 여기서 최적화 기법 백트래킹을 섞어주면, 5초 이내에 조건을 만족하는 보드를 얻을 수 있습니다.

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <string.h>

#define SIZE 49
#define N 7

char n_label[SIZE] = {
    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
};

int locked[SIZE] = {
    /* indices 0~7 */  0, 0, 0, 0, 0, 0, 0, 0,
    /* index 8 */       1,
    /* indices 9~15 */  0, 0, 0, 0, 0, 0, 0,
    /* index 16 */      0,
    /* index 17 */      1,
    /* indices 18~22 */ 0, 0, 0, 0, 0,
    /* index 23 */      1,
    /* index 24 */      0,
    /* index 25 */      1,
    /* indices 26~30 */ 0, 0, 0, 0, 0,
    /* index 31 */      1,
    /* indices 32~39 */ 0, 0, 0, 0, 0, 0, 0, 0,
    /* indices 40~44 */ 0, 0, 0, 0, 1,
    /* indices 45~48 */ 0, 1, 0, 1
};

static char seen[SIZE];
static char cnts[4];

bool dfs_component(int x, int y, char *state, char *seen, char *cnts, bool *open) {
    int idx = y * N + x;
    seen[idx] = 1;
    
    if ( n_label[idx] ) {
        cnts[n_label[idx]]++;
        if (cnts[n_label[idx]] > 2)
            return false;
    }
    
    int dx[4] = {0, 0, -1, 1};
    int dy[4] = {-1, 1, 0, 0};
    char color = state[idx];
    
    for (int i = 0; i < 4; i++) {
        int xx = x + dx[i];
        int yy = y + dy[i];
        if (xx < 0 || xx >= N || yy < 0 || yy >= N)
            continue;
        int nidx = yy * N + xx;
        if (!seen[nidx]) {
            if (state[nidx] == -1) {
                *open = true;
            } else if (state[nidx] == color) {
                if (!dfs_component(xx, yy, state, seen, cnts, open))
                    return false;
            }
        }
    }
    return true;
}

bool check_partial_state(char *state) {
    memset(seen, 0, sizeof(seen));
    for (int y = 0; y < N; y++) {
        for (int x = 0; x < N; x++) {
            int idx = y * N + x;
            if (state[idx] == -1)
                continue;
            if (seen[idx])
                continue;
            char local_cnts[4] = {0,0,0,0};
            bool open = false;
            if (!dfs_component(x, y, state, seen, local_cnts, &open))
                return false;
            if (!open) {  
                for (int i = 1; i < 4; i++) {
                    if (local_cnts[i] == 1)
                        return false;
                }
            }
        }
    }
    return true;
}

bool full_check(char *state) {
    return check_partial_state(state);
}

bool solve(char *state, int idx) {
    if (idx == SIZE) {
        return full_check(state);
    }
    if (locked[idx]) {
        state[idx] = 1;
        return solve(state, idx + 1);
    }
    for (int v = 0; v < 2; v++) {
        state[idx] = v;
        if (check_partial_state(state)) {
            if (solve(state, idx + 1))
                return true;
        }
        state[idx] = -1;
    }
    return false;
}

int main(void) {
    char state[SIZE];
    for (int i = 0; i < SIZE; i++) {
        state[i] = -1;
    }
    
    if (solve(state, 0)) {
        printf("===================================\n");
        printf("WINNER found!\n");
        printf("===================================\n");
        for (int y = 0; y < N; y++) {
            for (int x = 0; x < N; x++) {
                int idx = y * N + x;
                printf("%c", state[idx] ? '#' : '_');
            }
            printf("\n");
        }
        FILE *fp = fopen("WINNER.txt", "w");
        if (fp) {
            for (int y = 0; y < N; y++) {
                for (int x = 0; x < N; x++) {
                    int idx = y * N + x;
                    fprintf(fp, "%c", state[idx] ? '#' : '_');
                }
                fprintf(fp, "\n");
            }
            fclose(fp);
        }
        return 0;
    } else {
        printf("No solution found.\n");
        return 1;
    }
}

최대한 빠르게 해답을 얻기 위해 C 언어를 사용하였습니다.

bit-by-bit#

조금 시간이 지나고 나서 1000xREV으로 이름이 바뀐 것 같습니다. 참고하시면 좋겠습니다.

바이너리를 분석하면 rev.lac.tf 와 관련된 DNS 주소로 TXT 레코드 요청을 보내어 한 비트씩 플래그의 정보를 얻어내는 것을 알 수 있습니다.

여기서 10710^7개의 DNS 주소가 존재하는데, 각각의 주소는 다음 중 하나입니다:

  1. 플래그의 정보 및 다음 플래그를 얻기 위해 접속해야 하는 DNS 주소
  2. 다음 플래그를 얻기 위해 접속해야 하는 DNS 주소
  3. TXT 레코드가 없음

10710^7개의 주소를 모두 요청을 보내는 것은 잘못된 방향입니다. 서버에 무리가 갈 수 있고 또한 속도가 매우 느립니다.

삽질을 하던 도중 DNS 네임서버 ns1.rev.lac.tf가 AXFR 기능이 켜져있음을 알게 되었고, 이를 통해 모든 도메인 정보를 가져올 수 있었습니다.

dig axfr rev.lac.tf @ns1.rev.lac.tf > dump.txt

하지만 여기 보면, 플래그의 한 위치에 몇 가지 서로 다른 정보가 들어있는 경우가 있습니다. 아마 0부터 순서대로 1, 2, 3, 4, … 와 같이 검색한 사람들을 방지하는 것 같습니다. 0부터 차례대로 Linked List를 구성하여서 올바른 플래그를 얻으면 됩니다.

McFlagChecker#

마인크래프트 데이터팩 개발을 해 본 경험이 있어 쉬운 문제였습니다.

문제를 분석하면 check_flagf6, f5, f4, f1 네 가지 함수가 중요한 함수들이라는 점을 알 수 있습니다.

차례대로,

  1. f1는 두 수를 XOR하는 함수입니다.
  2. f4는 인자 pp를 받아, 97p+129mod25697p + 129 \mod 256 를 수행합니다.
  3. f5는 인자 aa, bb, cc를 받아, abmodca^b \mod c 를 수행합니다. 여기서 a,ca, c는 보통 각각 6, 251입니다.
  4. f6는 미리 정의된 행렬 MM과 인자 벡터 vv에 대해 연립하여 새로운 벡터 vrv_r를 반환합니다.

이를 바탕으로 복호화하면 됩니다.

#v1 = [145, 47, 213, 185, 157, 86, 191, 221, 98, 106, 202, 28, 168, 217, 37, 236, 131, 20, 10, 31, 138, 148, 115, 125, 190, 52, 55, 165, 205, 57, 81, 122, 170, 234, 120, 125, 188, 218, 154, 206]
from sage.all import *
from mat import g
v2 = [137, 193, 59, 168, 164, 129, 35, 165, 159, 193,
           12, 170, 90, 182, 156, 214, 172, 62, 59, 106,
           175, 186, 174, 231, 160, 56, 67, 221, 44, 68,
           90, 244, 192, 123, 140, 245, 218, 169, 58, 8]

M = matrix(GF(251), g)
V = vector(GF(251), v2)

v1 = list(M.solve_right(V))
print(v1)

for i in range(40):
    a = 0
    for j in range(40):
        a += g[i][j] * v1[j]
    assert(a % 251 == v2[i])

def f5(x, y=6, z=251):
    return (y ** x) % z

v3 = [0] * 40

for i in range(40):
    found = False
    for j in range(256):
        if f5(j) == v1[i]:
            v3[i] = j
            found = True
            break
    assert(found)

for i in range(40):
    assert(f5(v3[i]) == v1[i])

def f4(x):
    return (x * 97 + 129) % 256

origin = 106

for i in range(40):
    origin = f4(origin)
    print(chr(origin ^ v3[i]), end="")

elfisyou#

Baba is You 게임을 모티브로 한 것 같습니다. 게임 자체는 중요하지 않고, 169개의 바이트로 ELF 파일을 구성하여 실행되게 만든 다음, 어떻게든 flag.txt를 읽는 것이 목표입니다.

굉장히 막막합니다. 하지만 여기서 게임에 특성에 의거하여 얻을 수 있는 관찰이 있습니다.

게임을 보면 맨 끝에 있는 바이트의 경우 자리에 들어갈 수 있는 것이 제한되어있습니다. 가령, 맨 왼쪽 밑의 03, 맨 오른쪽 밑의 05는 고정되어있습니다. 이 외에도 맨 왼쪽 밑에서 두 번째 줄 48 또한 고정되어 있습니다.

이 정도만 알아도 충분합니다. 먼저, 쉘코드를 짜 보아야 합니다. 쉘코드를 짤 때 발생하는 바이트들의 엔트로피가 ELF 헤더를 구성할 때 발생하는 바이트들의 엔트로피보다 높기 때문입니다. 즉, ELF 헤더를 먼저 구성하면 나중에 쉘코드를 변경하기 힘들어지고, 역은 반대입니다.

우선 0f, 05 로 구성된 syscall 을 해야 한다는 것은 자명합니다. 0f, 05는 각각 2개 있으므로, syscall 최대 두 번으로 어떻게든 flag.txt를 읽어야 합니다.

재밌는 점은 169개의 바이트 중 flag.txt를 구성하는 바이트가 모두 있었다는 점입니다. 이로써 출제자가 의도하는 것은 쉘을 따거나 입력을 억지로 받는다는 것보다, 그저 심플하게 flag.txt를 읽는 것을 의도한 것으로 볼 수 있습니다.

그러면 첫 번째 syscall은 자명히 open이고, 두 번째 syscall은 따라서 sendfile일 것입니다.

여기서부터 삽질을 해나가면서 얻은 정보들을 종합하여 최종 완성된 쉘코드는 다음과 같습니다.

48 b8 66 6c 61 67 2e 74 78 74 50 48 89 e7 b8 02 00 00 00 0f 05 48 c7 c0 28 00 00 00 bf 01 00 00 00 be 03 00 00 00 49 c7 c2 00 00 01 00 0f 05
0: 48 b8 66 6c 61 67 2e movabs rax,0x7478742e67616c66  
7: 74 78 74  
a: 50 push rax  
b: 48 89 e7 mov rdi,rsp  
e: b8 02 00 00 00 mov eax,0x2  
13: 0f 05 syscall  
15: 48 c7 c0 28 00 00 00 mov rax,0x28  
1c: bf 01 00 00 00 mov edi,0x1  
21: be 03 00 00 00 mov esi,0x3  
26: 49 c7 c2 00 00 01 00 mov r10,0x10000  
2d: 0f 05 syscall

다시 말하지만 뚝딱 만드는 기법 같은 것은 없고, 그저 시행착오를 많이 거쳐가면서 얻어내면 됩니다. 차후 언급할 ELF Header와의 조율도 거쳐야 합니다.

ELF Header는 다음 두 가지 문서를 참조했습니다. Ref1 Ref2

여기서 실행에 필수불가결한 것들만 채우고 나머지는 남은 바이트로 채우게 되면 최종적으로 다음과 같은 ELF 파일이 만들어집니다.

7f 45 4c 46 02 01 01 00 00 00 00 00 00
00 00 00 03 00 3e 00 01 00 00 00 78 00
00 00 00 00 00 00 40 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 38 00 01 00 00 00 00 00 00 00 01
00 00 00 07 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 10 00 00 00 00 00 00
00 10 00 00 00 00 00 00 00 00 00 00 00
00 00 00 6a 00 48 b8 66 6c 61 67 2e 74
78 74 50 48 89 e7 b8 02 00 00 00 0f 05
48 c7 c0 28 00 00 00 bf 01 00 00 00 be
03 00 00 00 49 c7 c2 00 00 01 00 0f 05

이제 이대로 옮겨서 플래그를 얻으면 됩니다. 하지만 리모트에서 시간 제한이 은근 빡빡하다는 사실을 알았습니다. 어떻게 할까 고민하다가, 그냥 로컬에서 어떻게 했는지 로깅하고 이를 똑같이 리모트에서 빠르게 수행하는 것으로 해결하였습니다.

키로거는 Windows에 설치하면 바이러스로 인식되어서 Linux 환경에서 설치하여 진행하였습니다. Ref

마지막 페이로드는 다음과 같습니다.

sssssassdsssasaawddawwwdswsswwwwdsssssaaaawaasdwdsasdwddasdsaawawawaasswwaasswwasswwdwwaasssswdsdddwwdsasaawddddwsdwdsasdddwwwwawwddsdsaadwwaasswaassddwdssswwwawawwassssdssaawdwdssasdwwwddddwwwwwwasssssssdsaaadwwaasswwdddwwwwwwaaasssdsawasssssaaaaawwwwdsssswwwaassdddwwdwaswwwwssssawwdwaaasawwsddwwasasdwwddsswaasddaaasssdddddwwwwssawawdsssawwsssaawwawdsddawwawdssssdddwdddddwsaawwddasdsaaaaawasdsaasawwwwssssssddddddwddsaaaaasadwasawdwaasawwwwwwsssdddwdddwwdwasssssawawdsdwwsswaaaadwaasddssssdwwdwaddddddssaaaawasdddwaaaaax

prologue#

스크래치 프로젝트 웹사이트가 주어지는데, Celeste를 모방한 게임이라는 것을 알 수 있었습니다.

난독화가 심하게 되어 있다는 사실을 알 수 있습니다. 우선 sb3 파일을 json 형태로 변형하고 스키밍하던 중 Cheat Mode unlocked! 문자열을 발견할 수 있었고, 이 치트 모드를 발동시키면 플래그가 나오지 않을까 하는 마음으로 분석을 시작했습니다.

치트 모드를 발동하는 조건은 다음과 같습니다.

"147": {
    "opcode": "operator_equals",
    "next": null,
    "parent": "145",
    "inputs": {
        "OPERAND1": [3, [12, "fVar1", "fVar1"],
            [10, ""]
        ],
        "OPERAND2": [2, [4, 1]]
    },
    "fields": {},
    "shadow": false,
    "topLevel": false
},

Duck 오브젝트를 선택하고 보면, 굉장히 긴 코드블럭들이 나열되어 있는데 이게 치트 모드를 발동시키는 루틴이라는 것을 어렵지 않게 알 수 있었습니다.

치트 모드를 발동시키기 위해서는,

  1. 대쉬가 해금되어야 합니다.
  2. 대쉬를 정해진 방향과 순서대로 64번 수행해야 합니다.

이 루틴을 예측하는 데에는 제가 Celeste 게임을 많이 해서 알 수 있었습니다. Celeste 게임에서 이와 같은 퍼즐이 존재했기 때문입니다. 아무튼 여기서 스테이지별로 64개의 대쉬를 검증하는 것이 나눠져 있으며, 각 스테이지를 통과하는 대쉬의 방향을 나열하면,

 1: down right
 2: down right
 3: up
 4: left
 5: up
 6: down left
 7: down
 8: down right
 9: down right
10: left
11: up
12: left
13: down right
14: up right
15: up left
16: down right
17: down right
18: right
19: right
20: left
21: up left
22: up right
23: left
24: down
25: down right
26: down
27: down
28: down right
29: up
30: up right
31: down right
32: up left
33: down right
34: up
35: left
36: left
37: up
38: down left
39: left
40: right
41: right
42: up left
43: left
44: left
45: down right
46: down
47: left
48: up
49: right
50: up left
51: left
52: left
53: up right
54: up right
55: left
56: right
57: down right
58: down
59: down
60: up left
61: up right
62: up right
63: up left
64: down left

입니다. 이를 토대로 대쉬를 수행하면 치트 모드가 성공적으로 발동되는 것을 확인할 수 있지만, 문제는 플래그가 노출되거나 하는 일이 일어나지 않았습니다.

여기서 약간의 추측을 통해 진행해야 합니다.

up을 0, up right를 1, right를 2, … 순서대로 8개의 방향을 시계방향 순서대로 숫자로 변환하고, 이것을 8진수로 해석하여 숫자를 String으로 바꾸면 플래그가 나옵니다. Ref

마치며#

LA CTF 2025는 Dreamhack Invitational Quals와 겹쳐서 둘 중 무엇을 참가할까 고민하였습니다. 결국 2024년 LA CTF에 참가했을 때, 즉 고등학교 1학년 때의 나에 비해 지금의 나는 얼마나 성장했는가가 궁금했기에 LA CTF 2025를 참여하였고, 큰 성취를 거두게 되어 기쁩니다. 또 이에 더해서 2026년의 내가 현재의 나를 바라봤을 때 지금과 같은 기분을 느꼈으면 하는 바람이 있습니다. 아마.. LA CTF 2026에도 참여할 것 같네요. 읽어주셔서 감사합니다.

LA CTF 2025 Reversing Write-up
https://blog.wane.im/posts/la2025/
Author
Wane
Published at
2025-02-09