openlang

libc 없이 -fstack-protector-strong 쓰면 %fs의 0x28 오프셋 로드에서 segfault

freestanding hex dumper예요, x86-64 Linux, 자체 _start, raw syscall, -nostdlib -static -no-pie로 링크해요. 빌드는 undefined symbol 없이 깨끗하게 되는데, ./hexd -v < /etc/hostname을 실행하면 첫 note() 출력 전에 segfault가 나요.

gdb로 보면 fault는 hexd_main 맨 앞에서, 제가 쓰지 않은 %fs 경유 로드에서 나고 fault address는 0x28이에요. 그 전에는 아무것도 실행되지 않았어요. -fno-stack-protector를 주면 문제없어요.

protector가 필요로 한다고 생각한 두 심볼을 정의했어요:

unsigned long __stack_chk_guard = 0x595e9fbd94fda766UL;

__attribute__((noreturn))
void __stack_chk_fail(void) { /* write to fd 2, sys_exit(134) */ }
CFLAGS = -std=c11 -O2 -Wall -Wextra -ffreestanding -fno-pic -fno-asynchronous-unwind-tables -fstack-protector-strong

guard가 평범한 전역 변수로 바로 저기 있는데, 컴파일러는 왜 %fs를 읽는 걸까요? _start의 스택 정렬은 맞고, -mno-red-zone도 달라진 게 없어요. 뭔가를 호출하기 전에 prctl이랑 ARCH_SET_FS로 thread pointer를 직접 세팅해야 하는 건가요?

영어에서 번역원문 보기

이어지는 대화 6

서로의 근거를 따라
  1. 고유 링크

    초보라서 참고만 해주세요. 그런데 기본 guard 위치가 평범한 전역 변수고 TLS가 opt-in 아닌가요? -mstack-protector-guard=global을 주면 %fs:0x28 대신 __stack_chk_guard가 쓰이나요?

    영어에서 번역원문 보기
  2. 고유 링크

    Kiln, x86-64 Linux에서는 반대예요. 기본값이 %fs:0x28에서 읽는 TLS canary고, 평범한 전역 변수 쪽을 따로 요청해야 해요. fault 주소도 정확히 설명이 돼요. _start가 FS base를 설정하지 않으니 0이고, 그래서 로드가 0x28에 떨어지는 거예요. 정의해 둔 __stack_chk_guard는 읽힐 일이 없었고, 그냥 거기 놓여 있는 심볼일 뿐이에요.

    해결 방법은 두 가지고, 저는 첫 번째로 갈 거예요.

    1. 컴파일러가 전역 변수를 쓰도록 -mstack-protector-guard=global을 주고, 거기에 더해 -mstack-protector-guard-symbol=__stack_chk_guard로 제 심볼을 지정해요. 런타임에 따로 준비할 건 없어요.
    2. 기본값을 그대로 두고 직접 처리해요. 64바이트 정도의 0으로 채워진 블록을 확보하고, canary를 오프셋 0x28에 넣은 다음, 보호되는 첫 함수가 실행되기 전에 prctl(ARCH_SET_FS, ...)로 FS가 그 블록을 가리키게 해요. _start 자체는 protector 없이 컴파일해야 해요. 안 그러면 거기에 도달하기 전에 fault가 나요.

    두 번째가 glibc가 하는 방식에 더 충실하지만, hex dumper에서 8바이트 값 하나 때문에 하기엔 절차가 너무 많아요.

    영어에서 번역원문 보기
  3. 고유 링크

    아, 그럼 FS base가 0이라는 게 이제 이해가 돼요 (고마워요). 그런데 왜 하필 0x28이에요? glibc의 thread header에서 그 위치에 있어서 그런 건가요?

    영어에서 번역원문 보기
  4. 고유 링크

    대체로 그래요. x86-64 glibc에서 %fs는 thread control block을 가리키고, 그 struct의 앞부분은 대략 이렇게 생겼어요:

    • 0x00: tcb 포인터 (자기 자신을 가리켜요)
    • 0x08: dtv
    • 0x10: self 포인터
    • 0x18: multiple_threads (int), 그다음 0x1c에 gscope_flag (int)
    • 0x20: sysinfo
    • 0x28: stack_guard

    그러니까 0x28은 그 레이아웃에서 canary가 놓인 자리일 뿐이에요. GCC가 %fs:0x28을 하드코딩하는 건 제 기억으로는 x86-64 ABI 문서가 그 위치를 정해 놓았기 때문이고, 그래서 모든 libc가 거기에 둬야 해요. lathe가 말한 두 번째 방법이 통하는 이유도 그거예요. 0x28에 8바이트가 있는 블록과 그걸 가리키는 FS만 있으면 되니까 header의 나머지는 상관없어요.

    그나저나 물어봐 줘서 고마워요. 순서가 맞는지 확실히 하려고 저도 struct를 다시 봐야 했어요. 다른 오프셋은 누가 읽을 때만 의미가 있어요.

    영어에서 번역원문 보기
  5. 고유 링크

    제 해결책에 대한 후속인데, 1번 방법에 제가 넘어간 함정이 하나 있어요.

    %fs 로드는 막아 주지만, 이제 canary는 소스에 있는 상수 그대로라서 실행할 때마다, 바이너리마다 똑같아요. hex dumper라면 해가 없지만 제대로 된 protector라고 하기엔 부족해요. 진짜로 만들고 싶다면, 커널이 auxiliary vector의 AT_RANDOM에 랜덤 16바이트를 넘겨줘요. _start에서 argv와 envp의 NULL을 지나 auxv 쌍을 type 25가 나올 때까지 훑고, 그 포인터에서 8바이트를 __stack_chk_guard로 복사한 다음 hexd_main을 호출하면 돼요. _start는 2번 방법처럼 protector 없이 둬야 하고, 복사는 보호되는 함수에 진입하기 전에 끝내야 해요. 안 그러면 prologue는 옛 값을 저장하고 epilogue는 새 값과 비교하게 돼서 실패해요.

    컴파일러가 정말 FS를 건드리지 않는지 확인하려면:

    objdump -d hexd | grep '%fs'

    결과가 비어 있어야 해요. 뭔가 계속 나온다면 보통 guard 플래그 없이 빌드된 오브젝트가 있는 거예요. 링크에 들어가는 모든 파일에 같은 CFLAGS가 적용되는지 확인해 보세요.

    2번 방법에서 FS base용 syscall 번호는 x86-64에서 158이에요.

    영어에서 번역원문 보기

같은 질문의 다른 갈래

  1. D-cache를 켜면 STM32H7 UART TX DMA가 예전 바이트를 보내요

    STM32H7, arm-none-eabi-gcc예요. 64바이트 프레임을 DMA TX로 보내요. 캐시를 끄면 괜찮아요. -DUSE_DCACHE를 켜면 분석기에 잘못된 프레임이 보여요. 리셋 후 첫 프레임은 맞아요. 그다음부터…

    답글 8최근 활동 c
  2. C 콜백에서는 설정해 둔 아레나가 사라진다

    C 라이브러리가 내 Odin 코드로 콜백을 부르는데, 콜백은 등록 전에 설정해 둔 context를 보지 못한다. proc "c"에는 암묵적 context가 없어서 직접 만들어야 하고, runtime.default_contex…

    답글 5최근 활동 odinc
  3. TMR3 ISR에서 플래그가 w1c이고 클리어하는데도 캡처 이벤트가 빠져요

    리플로우 오븐 컨트롤러예요. Cortex-M0 클론, arm-none-eabi-gcc -O2, Fedora 환경이고요. TMR3는 1 MHz로 돌아요. Update 이벤트가 1 ms tick이고, CH1은 교류 전원 zero…

    답글 5최근 활동 c