하나의 소켓으로 leak하고 stage2까지: 역방향 단일 소켓 스테이징
어떤 진단에서 “이건 좀 특이한 패턴 아닌가” 싶은 익스플로잇을 하나 완성했다. 악성 서버가 접속해온 클라이언트를 치는데, leak과 2차 페이로드를 이미 열린 하나의 소켓으로 단일 ROP 체인 안에서 처리했다. 인상적이라고 생각했는데, 파고들수록 결론은 정반대였다. 기법은 하나도 새롭지 않았고, 그 사실이 오히려 더 유용한 이야기였다.
대상 제품의 세부는 조율된 공개(coordinated disclosure)가 끝날 때까지 밝히지 않는다. 이 글과 함께 공개하는 코드는 전부 합성 예제다. 실제 바이너리·실제 가젯 주소·실제 프로토콜은 없다. 기법 자체가 이미 공개된 것들이라 이렇게 해도 설명에 부족함이 없다.
1. “특이하다"는 직감의 함정
레거시 네이티브 클라이언트를 하나 보다가 전형적인 결함을 만났다. 서버가 보낸 길이 값을 상한 검사 없이 고정 스택 버퍼에 recv한다. 서버가 길이를 크게 부르면 저장된 반환주소가 덮인다. 빌드는 옛날 그대로 No PIE / No canary / NX / Partial RELRO.
여기까지는 흔하다. 그런데 익스플로잇을 짜다 보니 조건이 묘했다. 대상이 단발성 클라이언트였다. 요청 한 번 처리하고 죽는 프로세스. 그리고 공격 방향이 뒤집혀 있었다 — 내가 서버를 사칭/중간자로 잡고, 접속해온 클라이언트를 친다.
그래서 leak과 실행을 하나의 커넥션·하나의 오버플로우·하나의 ROP 체인 안에 다 넣어야 했다. 이걸 소켓 유출로 풀었을 때 “이거 좀 특이한데?” 싶었다. 결론부터 말하면, 이 직감은 절반만 맞았다.
2. 취약점 유형(CWE)도, 공격 패턴(CAPEC)도 새롭지 않다
먼저 분류부터. 이게 정말 새로운 것이라면 어딘가 등록되지 않은 유형이어야 한다. 그런데 아니다.
약점 자체는 기존 CWE의 정석적 조합이다.
- 근본원인: CWE-130 (Improper Handling of Length Parameter Inconsistency), CWE-20
- 발현: CWE-805 (Buffer Access with Incorrect Length Value), CWE-121 (Stack-based Buffer Overflow)
- 전송/신뢰: CWE-319 (Cleartext Transmission), CWE-345 (Insufficient Verification of Data Authenticity)
공격 패턴도 마찬가지다. CAPEC은 의도적으로 추상적이라 “front-load ROP” 같은 익스플로잇 단위를 담지 않는다. 이 공격은 기존 CAPEC 체인으로 그대로 표현된다.
딜리버리: CAPEC-94 (Adversary in the Middle)
+ CAPEC-384 (API Message Manipulation via MitM)
+ CAPEC-194 (Fake the Source of Data)
익스플로잇: CAPEC-100 (Overflow Buffers)
+ CAPEC-8 (BOF in an API Call)
+ CAPEC-9 (BOF in Local Command-Line Utilities)
CWE도 CAPEC도 새로 등록할 게 아니라, 이미 있는 것들을 정확히 매핑할 대상이다. 클래스의 신규성으로 따지면 이 발견은 특이하지 않다. 그럼 내 직감은 완전히 틀린 걸까?
3. 특이한 건 “유형"이 아니라 “익스플로잇 제약"이었다
직감이 가리킨 진짜 지점은 분류가 아니라 익스플로잇 조건의 난이도였다.
교과서적인 원격 익스플로잇 — 예컨대 ropasaurusrex류 — 은 이렇게 흐른다.12
서버 프로세스에 연결
-> 오버플로우 #1: write(fd, GOT, len)으로 libc leak, 취약 함수로 복귀
-> 같은 프로세스가 루프를 돌며 stage2를 다시 read
-> 오버플로우 #2: 이제 실제 주소로 ret2libc system("/bin/sh")
이게 성립하는 이유는 타깃이 재진입 가능한 서버이고, 한 프로세스가 연결 내내 유지되기 때문이다. 오버플로우 #1에서 leak한 base가 오버플로우 #2에서 그대로 유효하다.
역방향 단발 클라이언트에서는 이 루프가 원천 봉쇄된다.
- 재진입이 없다. 단발 CLI라 “취약 함수로 복귀해 다시 read” 같은 게 없다.
- 매 실행마다 ASLR이 새로 뽑힌다. 프로세스 A에서 leak한 base는 프로세스 B에서 아무 의미가 없다. “요청 A에서 leak, 요청 B에서 pwn"이 죽는다.
그래서 leak과 stage2 실행이 반드시 같은 프로세스·같은 커넥션·같은 체인에 들어가야 한다. 이게 직감이 감지한 진짜 특이점이었다 — 유형이 아니라 제약.
4. 이미 열린 소켓 하나로, 두 방향으로
그 제약의 답은 사실 다른 동네에 이미 있었다. Borja Merino의 Windows-One-Way-Stagers가 다루는 아이디어 — 방화벽·제한 환경에서 새 연결을 열지 말고 이미 열린 소켓을 재사용하라 — 가 그것이다.3
역방향 클라이언트 익스플로잇에서는 이 소켓이 자연스럽게 유일한 채널이다. 피해자가 나(악성 서버)에게 연 그 연결이, leak을 내보내는 통로이자 stage2를 받아들이는 통로다. one-way stager를 두 방향(leak out + stage2 in)으로, 단일 체인 안에서 돌리는 셈이다.
방향 자체(“악성 서버가 클라이언트를 친다”)도 새롭지 않다. Check Point가 2019년 Reverse RDP Attack으로 이름 붙여 대중화한 클래스다.4 그 뒤로도 “서버가 제어하는 길이 필드 + 클라이언트 복사 루틴의 경계검사 부재 → 클라이언트 RCE"는 꾸준히 CVE가 된다(FreeRDP 계열 등).
즉 재료는 전부 공개돼 있었다. 내가 한 건 재료를 이 제약 아래 하나로 조립한 것뿐이다.
5. 체인
핵심만 추리면 이렇다. stage-1 ROP는 피해자 안에서 실행되며, 전부 연결된 소켓(fd 3) 위에서 벌어진다.
pop rdi ; 3
pop rsi ; send@got
pop rdx ; 8
g_send0 # send(3, send@got, 8, 0) -> libc 'send' 주소를 소켓으로 유출
pop rdi ; 3
pop rsi ; STAGE2
pop rdx ; 0x100
g_recv0 # recv(3, STAGE2, 0x100, 0) -> 여기서 블로킹, stage2를 기다림
pop rsp ; STAGE2 # 받은 stage2로 스택 피벗
악성 서버(나)는 8바이트 leak을 받은 뒤에야 stage2를 만든다.
libc_base = leak - libc.sym.send
stage2 = [ ret ; pop rdi ; &cmd ; system ] + b"id > /tmp/pwned; ...\0"
send(stage2) # 피해자의 recv가 풀리고 -> 피벗 -> system(cmd)
system 앞의 ret 하나는 16바이트 정렬(movaps 함정) 보정이다. STAGE2는 고정·매핑된 전역 상단에 둬서 system이 아래로 프레임을 쓸 공간을 확보한다.
왜 한 패킷이 아니라 두 패킷인가
stage2에는 런타임 system 주소가 박힌다. 그 값은 leak이 나에게 되돌아온 뒤에야 안다. 그러니 stage2는 stage1과 물리적으로 동봉 불가능하다. 그런데도 이게 단일 커넥션인 이유는, stage1 체인이 자기 안에 recv 가젯을 품고 있어서 피해자가 leak을 뱉은 직후 체인 중간에서 recv로 멈춰 같은 소켓으로 stage2를 기다리기 때문이다. 왕복이 스스로 동기화를 만든다.
악성서버 ──(1) 오버플로우 응답────▶ 피해자
피해자 ──(2) send(send@got) ────▶ 악성서버 # leak
악성서버 ──(3) stage2(실주소) ────▶ 피해자 # recv 풀림 -> 피벗 -> system()
6. 방어 경계는 ASLR도 카나리도 아닌 PIE다
이 익스플로잇이 full ASLR에서 uid=0을 내는 건, ASLR이 무력해서가 아니라 바이너리가 non-PIE라서다. non-PIE면 코드·PLT·GOT·가젯 주소가 전부 고정이라, 소켓 leak으로 libc만 한 번 풀면 ret2libc가 성립한다. 카나리 부재나 null-truncation 유무는 방벽이 아니다.
반대로 PIE + full ASLR이면 가젯 주소 자체가 무작위라 최초 leak의 고정 앵커가 사라진다. 같은 버그가 원격 DoS 상한으로 내려간다. 그래서 운영 바이너리의 checksec(특히 PIE 여부)이 최종 심각도를 가른다. 조치도 명확하다 — 경계 검사 + PIE·카나리·Full RELRO 재빌드 + TLS/서버 인증 + 응답 무결성.
7. 직접 돌려보기
기법을 그대로 재현하는 합성 랩을 공개한다. 실제 제품이 아니라, 같은 성질(non-PIE·서버제어 길이 오버플로우·역방향)을 가지도록 내가 새로 짠 데모다.
저장소: github.com/windshock/Linux-Reverse-Socket-Stagers
git clone https://github.com/windshock/Linux-Reverse-Socket-Stagers
cd Linux-Reverse-Socket-Stagers/01-single-socket-reverse-stager
# Apple Silicon: colima start --arch x86_64
./run.sh
격리 컨테이너에서 full ASLR을 켜고, 단일 악성 응답으로 uid=0을 재현한다. 프로토콜·가젯·오프셋은 전부 이 데모의 것이며, 어떤 제품과도 무관하다.
저장소에는 러너블 랩 세 개가 들어 있고, 셋 다 격리 x86_64 컨테이너에서 full ASLR uid=0으로 검증됐다(각각 marker 증명 + 재사용 소켓 위 실제 /bin/sh).
- 01 단일 소켓 역방향 스테이징 — 본문의 핵심 기법.
- 02 front-load 소스하이잭 — 후속 memcpy가 RIP를 응답 앞부분에서 가져올 때, 체인을 payload offset 0에 싣는 기법.
- 03 ret2csu + flags=0 —
pop rdx·pop rcx없이send/recv를 ROP로 구동.rdx는__libc_csu_init(ret2csu),flags=0은 실제 래퍼 함수로 확보.
8. 정리
처음 질문은 “이거 특이한 패턴 아냐?“였다. 정직한 답은 이렇다.
- 유형으로는 특이하지 않다. CWE도 CAPEC도 새로 만들 게 아니라 기존 것을 매핑할 대상이다. staged GOT-leak → ret2libc는 교과서고, 소켓 재사용 스테이징도, 역방향 클라이언트 공격도 전부 공개된 계보다.
- 제약으로는 의미가 있다. 단발 프로세스 + full ASLR + 크로스커넥션 leak 불가라는 더 어려운 조건에서, 이미 열린 하나의 소켓으로 leak과 stage2를 단일 체인에 담아낸 조립이 이 작업의 값어치다.
기법의 신규성과 발견의 가치는 별개다. 흔한 클래스일수록 “왜 아직도 안 고쳐졌나"가 부각되고, 그 자체가 심각도 서사를 강화한다. 그리고 그 조립을 공개된 언어(CWE/CAPEC/선례)로 정확히 설명할 수 있다는 게, 인상에 취하는 것보다 훨씬 쓸모 있다.
9. 부록 — LLM과 함께 이 분석을 할 때 진짜 병목
이 글의 익스플로잇은 한 번에 나온 게 아니다. 가설을 세우고, 실측으로 뒤집고, 다시 좁히는 과정을 여러 번 반복했다. 그 과정에서 배운 건, LLM과 함께 취약점을 분석할 때 가장 큰 병목이 고급 기술 지식의 부재가 아니라는 것이었다. 진짜 병목은 그럴듯한 설명을 너무 빨리 믿어버리는 것이다.
대상은 웹 서버가 서드파티 서버와 통신하려고 띄우는 서버측 네이티브 헬퍼 바이너리였다. 진짜 관문은 “오버플로우가 된다"를 보이는 게 아니라, ASLR이 켜진 상태에서 같은 프로세스 안에서 체인이 이어짐을 보이는 것이었다 — leak → Stage2 → pivot → 실행 확인.
분석 내내 LLM은 비슷한 실수를 반복했다. GDB 관측 한 번을 사실로 취급했고, recv 실패를 소켓 상태 문제로만 해석했으며, ROP 체인이 놓여야 할 위치를 오해했고, 정적 ELF 정보와 실제 런타임 메모리 맵을 뒤섞었다. 결국 열쇠는 더 정교한 익스플로잇 아이디어가 아니라 LLM이 너무 일찍 결론으로 점프하지 못하게 강제하는 것이었다.

그래서 이런 분석에는 몇 가지 규칙을 강제하기로 했다.
- GDB 관측 한 번은 증거가 아니다. 브레이크포인트에서 한 번 본 레지스터 값이나 크래시 상태는, 반복 실행·strace·메모리 덤프·런타임 맵으로 확인하기 전까지는 가설일 뿐이다.
- recv를 fd·buf·len만으로 분석하지 마라. 네 번째 인자 flags도 동작을 바꾸고 EAGAIN을 유발할 수 있다. fd·buf·len·flags·반환값·errno를 함께 봐라.
- PLT나 libc가 인자를 대신 정리해줄 거라 가정하지 마라. 정상 래퍼는 인자를 맞게 준비하지만, 직접 ROP나 함수 중간 진입은 그 혜택을 못 받는다. 함수 인자는 호출자의 책임이다.
- 정적 ELF 정보와 런타임 매핑을 분리하라. readelf·objdump의 섹션·세그먼트로는 부족하다. 실행 중 프로세스에서 실제로 매핑·쓰기 가능한 영역을 확인하라.
- 내부 래퍼부터 찾아라. libc나 syscall 호출을 새로 짜기 전에, 바이너리에 select·timeout·recv flags·인자 세팅을 위한 안정적인 경로가 이미 있는지 확인하라.
- ASLR이 켜졌으면 같은 프로세스 연속성을 검증하라. 유출한 주소는 그 프로세스 인스턴스 안에서만 유효할 수 있다. leak → Stage2 → pivot → 실행 확인이 같은 프로세스에서 일어나야 한다.
- 확정 사실과 가정을 분리하라. 결론을 네 칸으로 적어라 — 확정된 사실, 반증된 가설, 미지, 다음 실험.
이 규칙들은 추상론이 아니다. 규칙 2·5(recv flags와 내부 래퍼)는 이 저장소의 **03(ret2csu + flags=0)**이 그대로 실증하고, 규칙에 나온 “ROP 체인 배치 오해"는 **02(front-load)**가, “같은 프로세스 연속성"은 01이 코드로 보여준다. 즉 이 글의 실패들이 곧 세 개의 러너블 랩으로 굳어졌다.
참고
HackTricks, “Leaking libc address with ROP” — staged GOT-leak → ret2libc 템플릿. https://book.hacktricks.wiki/en/binary-exploitation/rop-return-oriented-programing/ret2lib/rop-leaking-libc-address/index.html ↩︎
write(fd, GOT, len)소켓 leak + 2단계 ret2libc의 대표 write-up 예. https://github.com/jakecraige/ctf/blob/master/csaw-quals-2020/roppity/writeup.md · ret2csu/pivot 참고: https://ropemporium.com/ ↩︎Borja Merino, “Windows-One-Way-Stagers” — 소켓 재사용/재바인드 스테이거. https://github.com/BorjaMerino/Windows-One-Way-Stagers ↩︎
Check Point Research, “Reverse RDP Attack: Code Execution on RDP Clients” (2019). https://research.checkpoint.com/2019/reverse-rdp-attack-code-execution-on-rdp-clients/ ↩︎