
컴파일러가 생성한 메모리 로드를 탐지하여 안전한 C 코드가 TOCTOU 취약점으로 변질되는 것을 방지합니다. 자동화된 소스 감사, Unicorn 기반 바이너리 분석, 100개 이상의 프로젝트에 걸친 컴파일러/아키텍처/플래그 스윕을 포함합니다.
"...'정상적인 컴파일러'의 정의는 점점 더 느슨해진다."
여러분이 실행하는 바이너리는 여러분이 작성한 프로그램이 아니다. 컴파일러 최적화기는 소스를
여러분이 결코 보지 못할 방식으로 다시 작성하며 — 그러한 변경 중 일부는
조용하고도 합법적으로 겉보기에는 안전한 코드를
취약한 바이너리로 바꿀 수 있다. 같은 코드 줄은
한 컴파일러에서는 안전하고 다른 컴파일러에서는 공격에 취약할 수 있으며,
소스에는 어느 쪽인지 알려 줄 것이 없다: 그 취약성은 중첩 상태에
있다가 빌드할 때에만 붕괴된다. Schrödinger's TOCTOU는
**컴파일러가 만들어 낸 로드**와 그것이 오픈소스
커널,
하이퍼바이저,
엔클레이브,
펌웨어,
및 라이브러리에서 발견되는 검사 시점-사용 시점(TOCTOU) 취약성에
미치는 광범위한 영향을 탐구한다.
우리가 살펴본 모든 곳에서 겉보기에 안전한 코드는 컴파일러의 변덕에
노출되어 있다. 그러나 그것들은 표본일 뿐 경계가 아니다;
같은 버그는 여러분의 코드에도 있을 가능성이 매우 높다.
"간단한 것부터 시작해 보자."
이 함수는 *p를 몇 번 로드할까?```c
unsigned int g(unsigned short *p)
{
short t = p; / copy *p into a local for safekeeping */
return (unsigned short)t - t;
}
힌트: 답은 1입니다 — 소스는 `*p`를 `t`로 정확히 한 번 로드합니다.
이것을 [Compiler Explorer](https://godbolt.org/z/c5K9P4dPd) (`arm gcc 14.2.0`,
`-O2`)에 붙여넣고 `p`를 보관하는 `r0`부터의 로드 횟수를 세어 보세요.```asm
g:
ldrh r2, [r0] # load *p, once
ldrsh r0, [r0] # load *p, twice
subs r0, r2, r0
bx lr
소스에서 한 번의 로드, 바이너리에서는 두 번의 로드가 발생한다. 두 번째는 조작된 로드(invented load) 다 — 컴파일러가 만들어 낸, 당신이 결코 작성하지 않은 읽기다. 이는 C 추상 머신에서 합법적이며, 추상 머신은 두 번의 읽기 사이에 메모리가 변경될 수 없다고 가정한다. 하지만 그 메모리가 공격자가 쓰기 가능한 상태라면, 그 가정은 익스플로잇이 된다: 조작된 로드는 보안 검사 이후에 실행되어, 프로그래머가 닫았다고 믿었던 검사 시점-사용 시점(TOCTOU) 창을 조용히 다시 열어 버린다. 당신이 검증한 값과 당신이 사용하는 값은 더 이상 동일함이 보장되지 않는다 — 그 값을 다시 읽는 코드를 결코 작성하지 않았는데도 말이다.
이 도전 과제는 조작된 로드가 실제로 존재함을 증명한다. 그것이 어떻게 메모리 손상으로 이어지는지 살펴보자.
TOCTOU 취약점에서는 프로그램이 값이 안전한지 확인한 다음 그 값을 사용한다. 그러나 공격자가 그 두 읽기 사이의 아주 짧은 시간에 값을 변경할 수 있다면 악용의 창이 존재한다 — 무해한 값은 검사를 통과하지만 실제로 사용되는 것은 위험한 값이다:```c if (shared->len <= 20) // CHECK reads shared->len // ** attacker modifies shared->len ** memcpy(out, shared->data, shared->len); // USE reads it again: buffer overflow
교과서적인 해결책은 **스냅샷을 먼저 찍는 것**이다. 공격자가 건드릴 수 있는 데이터는 공격자가 닿을 수 없는 로컬(local)로 복사한 다음, 그 로컬 외에는 아무것도 신뢰하지 않는 것이다. 일단 `len`이 로컬에 들어가면 고정된다. 공유 메모리를 두고 경쟁하는 공격자가 더 이상 그 값을 건드릴 수 없기 때문에, 검사와 복사가 동일한 값을 보는 것이 보장된다. 아래 `receive`의 코드가 바로 이 방식으로 TOCTOU를 해결한다. 메시지를 스냅샷으로 찍고, 스냅샷을 검증한 뒤, 검증된 복사본을 `slot`에 게시하여 소비자가 전달할 수 있게 한다.```c
#include <string.h>
struct message {
int len; /* payload length */
char data[20]; /* payload */
};
struct message slot; /* the most recently validated message */
char out[20]; /* fixed 20-byte destination */
void receive(struct message *shared) {
struct message local = *shared; /* 1. snapshot untrusted input */
if (local.len <= 20) /* 2. validate the snapshot */
slot = local; /* 3. publish the validated copy */
}
void forward(void) { /* the time of use, later */
memcpy(out, slot.data, slot.len); /* slot.len was checked <= 20 ... right? */
}
소스에 따르면 이는 정확하다. len은 정확히 한 번만 읽힌다 — 스냅샷으로 —
따라서 <= 20 검사를 통과시키는 값은 slot에 게시된 값이다.
TOCTOU 창은 닫혀 있고 코드는 안전하다.
하지만 그렇지 않다. x86-64 gcc -O2에서, receive는 이 값을
원본 공유 메모리에서 두 번: 한 번은
검사를 통과시키는 스칼라로, 그리고 다시 slot에 게시되는 대량
복사의 일부로 읽는다:```nasm
receive:
cmp DWORD PTR [rdi], 20 ; READ #1: the CHECK reads shared->len directly
movdqu xmm0, XMMWORD PTR [rdi] ; READ #2: the bulk copy re-reads it (len is byte 0)
mov rax, QWORD PTR [rdi+16] ; (the bulk copy's tail: struct bytes 16-23)
jg .L1 ; len > 20? skip the publish
mov QWORD PTR slot[rip+16], rax ; (publish that tail)
movaps XMMWORD PTR slot[rip], xmm0 ; and publish the TOCTOU-vulnerable snapshot
.L1:
ret
forward:
movsx rdx, DWORD PTR slot[rip] ; copy size = slot.len, the unchecked READ #2 value
mov esi, OFFSET FLAT:slot+4 ; src = slot.data
mov edi, OFFSET FLAT:out ; dst = out[20]
jmp memcpy ; copies slot.len bytes into out[20]
검사는 READ #1에서 실행되고, `slot.len`에 들어가는 값은 READ #2입니다. 그 사이에 `len`을 뒤집는 공격자는 `<= 20` 검사에 안전한 값을 통과시키면서도 과대한 값을 `slot`에 게시합니다 — 그러면 `forward`는 그 많은 바이트를 `out[20]`으로 복사하는데, 이는 스냅샷이 막으려던 바로 그 오버플로가 옵티마이저에 의해 재도입된 것입니다.
이것은 [`poc/example.c`](https://github.com/xoreaxeaxeax/schrodingers-toctou/blob/main/poc/example.c)에서 완전한 개념 증명으로 구체화됩니다. 이 코드는 정식 TOCTOU 방어 접근 방식을 사용합니다: 신뢰할 수 없는 `message` 구조체는 수정할 수 없도록 `local`로 스냅샷되고, 스냅샷의 `local.len`은 버퍼 용량에 대해 검증되며, 검증된 복사본만 `slot`에 게시됩니다. 이후 소비자가 `slot.len` 페이로드 바이트를 고정 버퍼로 복사합니다. 동시에 공격자는 `shared->len`을 레이스합니다. 컴파일러의 예기치 않은 발명된 로드가 대량 게시를 위해 `shared->len`을 다시 읽으므로, 검사가 통과했음에도 `slot.len`은 공격자의 과대한 값을 담게 됩니다 — 프로그래머가 막으려던 TOCTOU를 재도입하면서, 공중에서 튀어나온 것 같은 불가능해 보이는 버퍼 오버플로를 만들어냅니다.
## Cause
> *C가 기계 코드에 도달할 때쯤, 그것은 프론트엔드 로어링, IR 최적화, 레지스터 할당, 백엔드 코드 생성에 의해 다시 형성됩니다 — 보이지 않는 결정을 내리는 깊고 다단계 파이프라인입니다. 탓할 단 한 단계는 없습니다. 발명된 로드는 전체 파이프라인의 창발적 속성이지, 어떤 부분의 버그가 아닙니다.*
이 시점에서: 컴파일러는 *실제로* 발명된 로드를 생성할 수 있으며, 버그를 막기 위한 바로 그 관용구 — 스냅샷, 검증, 사용 — 가 그 버그를 재도입합니다. (실제로 취약한지 알기 위한) 다음 단계는 *언제* 발생하는지를 규명하는 것입니다. 알고 보니 그건 어렵습니다.
[`cat-states/`](https://github.com/xoreaxeaxeax/schrodingers-toctou/blob/main/cat-states)에서는 그것이 실재하며 어디에나 존재함을 보여주는 개념 증명을 찾습니다:
| 메커니즘 | 툴체인 | 대상 |
|---|---|---|
| [**Rematerialization**](https://github.com/xoreaxeaxeax/schrodingers-toctou/blob/main/cat-states/README.md#rematerialization-class-1) | GCC, Clang, ICX, ICC, MSVC | x86-64, i386, m68k, VAX, MSP430 |
| [**Width-mismatch reload**](https://github.com/xoreaxeaxeax/schrodingers-toctou/blob/main/cat-states/README.md#width-mismatch-reload-class-2) | GCC | ARM, MIPS, MIPS64, RV64, s390x |
| [**Bulk-vs-scalar overlap**](https://github.com/xoreaxeaxeax/schrodingers-toctou/blob/main/cat-states/README.md#bulk-vs-scalar-overlap-class-3) | GCC, Clang, ICX, MSVC | x86-64, ARM, AArch64, AVR, Xtensa, SPARC, PPC64, s390x, MIPS64, RV64, m68k, MSP430, VAX, HPPA |
| [**Cross-class reload**](https://github.com/xoreaxeaxeax/schrodingers-toctou/blob/main/cat-states/README.md#cross-class-reload-class-4) | GCC | x86-64, s390x |
| [**CISC mem-op fold**](https://github.com/xoreaxeaxeax/schrodingers-toctou/blob/main/cat-states/README.md#cisc-alu-mem-op-fold-class-7) | GCC, Clang | m68k, MSP430, s390x, VAX, 6502 |
| [**Byte-order reload**](https://github.com/xoreaxeaxeax/schrodingers-toctou/blob/main/cat-states/README.md#byte-order-divergent-reload-class-8) | GCC | s390x |