autopwn 개발기 ③ — 첫 라이브 런, SSE 67줄, 그리고 남은 nmap 버그

2026-05-17·1분 읽기·

autopwn 개발기 ③ — 첫 라이브 런, SSE 67줄, 그리고 남은 nmap 버그

scope/allowlist 등록 → /scope/check 거부·허가 동작 확인 → /playbooks/run POST → SSE 라이브 관찰 → 15분 후 succeeded. 백엔드 골격은 의도대로 작동, 다만 nmap adapter의 결과 파싱에 작은 구멍이 남았다.

시리즈: autopwn 개발기 ③ 이전 글: ② Phase 1을 막은 세 개의 함정 이 글의 범위: 패치 끝난 백엔드에 실제 타깃 박고 첫 recon_basic 한 번 돌리기. 무엇이 됐고, 무엇이 미흡했나.

타깃 호스트는 본인이 회선·운영권을 가진 호스트지만 글에서는 198.51.100.x (RFC5737 documentation prefix) 로 마스킹한다.


🧭 셋업 — scope 등록

scope/allowlist.yaml 은 디폴트로 빈 상태. 한 줄 추가한다:

networks:
  - 198.51.100.42/32        # 본인 관리 호스트 (legacy 홈페이지)
 
deny:
  - 127.0.0.0/8
  - 169.254.0.0/16
  - 224.0.0.0/4
 
require_scope_for_discovery: true
proxy_required_for_external: true

요점:

  • networks:/32 로 단일 호스트 등록
  • deny:networks: 를 이긴다 (RFC1918 안 풀어줘도 디폴트로 막힘은 유지)
  • proxy_required_for_external: true — 랩 CIDR 밖이면 PROXY_CHAIN=true 가 아니면 거절

🚦 scope guard 동작 확인

거부 케이스 먼저:

curl -s -H "X-API-Key:$KEY" -H "Content-Type: application/json" \
  -d '{"host":"8.8.8.8"}' http://127.0.0.1:8080/scope/check
# {"target":"8.8.8.8","allowed":false,"reason":"target is not in any allow entry"}

허가 케이스:

curl -s -H "X-API-Key:$KEY" -H "Content-Type: application/json" \
  -d '{"host":"198.51.100.42"}' http://127.0.0.1:8080/scope/check
# {"target":"198.51.100.42","allowed":true,"reason":"matches network entry"}

실제 출력:

# 등록 안 된 외부 IP → deny
$ curl -s -H "X-API-Key:$KEY" -d '{"host":"8.8.8.8"}' \
    http://127.0.0.1:8080/scope/check
{
  "target": "8.8.8.8",
  "allowed": false,
  "reason": "target is not in any allow entry"
}
 
# allowlist 의 lab 네트워크 → allow
$ curl -s -H "X-API-Key:$KEY" -d '{"host":"172.18.0.12"}' \
    http://127.0.0.1:8080/scope/check
{
  "target": "172.18.0.12",
  "allowed": true,
  "reason": "matches network entry"
}

이 두 응답이 의도대로 갈라지는 게 합법성 가드의 핵심. "등록 안 한 IP는 절대 어댑터 진입 못함" 이 코드로 확정.


🎬 라이브 런 — recon_basic 트리거

먼저 SSE 채널을 별 터미널에 미리 열어둔다:

curl -N -H "X-API-Key:$KEY" http://127.0.0.1:8080/events/stream

그리고 플레이북 실행:

curl -s -H "X-API-Key:$KEY" -H "Content-Type: application/json" \
  -d '{"playbook":"recon_basic","target":"198.51.100.42"}' \
  http://127.0.0.1:8080/playbooks/run

응답:

{
  "run_id": "bf07939f7eca403e87ba7ee8945f4fba",
  "playbook": "recon_basic",
  "target": "198.51.100.42",
  "state": "queued",
  "steps": [
    { "id": "discover", "state": "pending" },
    { "id": "vuln_hints", "state": "pending" }
  ],
  "created_at": "2026-05-17T15:35:02Z"
}

플레이북 정의는 두 step. discover 가 -sT -Pn --top-ports 1000 -sV 로 깔고 — vuln_hints 가 그 결과에 open port가 있을 때만 banner/http-title 스크립트로 후속 스캔.

# api/playbooks/recon_basic.yaml
name: recon_basic
description: TCP connect 스캔 + 서비스 핑거프린팅. Proxychains/Tor 친화적.
inputs:
  - target
disallow_ai: false
steps:
  - id: discover
    tool: nmap
    args:
      flags: "-sT -Pn --top-ports 1000 -sV --reason"
 
  - id: vuln_hints
    tool: nmap
    when: "len(ctx['results']['discover']['open_ports']) > 0"
    args:
      flags: "-sT -Pn --script=banner,http-title -p {{ results.discover.open_ports_csv }}"

-sT (TCP connect) 만 쓴다 — proxychains/Tor 환경에서는 raw 패킷이 통과 안 되기 때문에 -sS 같은 SYN 스캔은 의미가 없다. nmap adapter 자체가 -sS/-sU/-A/-O 같은 raw 플래그를 strip 하고 -sT -Pn 을 강제한다.


📡 SSE 라이브 흐름

15분 동안 들어온 이벤트:

event: ready
data: {"ts":"...","channels":["autopwn:events","autopwn:events:scan","autopwn:events:session"]}
 
event: run.started
data: {"run_id":"bf07939...","playbook":"recon_basic","target":"198.51.100.42"}
 
event: step.running
data: {"run_id":"bf07939...","step":"discover","summary":null}
 
event: heartbeat
data: {"ts":"..."}
... (60회 heartbeat × 15초) ...
 
event: step.succeeded
data: {"run_id":"bf07939...","step":"discover","summary":"1 host(s), 8 open ports [proxied/connect-only]"}
 
event: step.skipped
data: {"run_id":"bf07939...","step":"vuln_hints","summary":"when=false"}
 
event: step.succeeded
data: {"run_id":"bf07939...","step":"vuln_hints"}
 
event: run.succeeded
data: {"run_id":"bf07939...","state":"succeeded"}

실제 lab(172.18.0.12 — Solr 8.11.0)에서 받아 본 SSE 흐름:

$ curl -N -H "X-API-Key:$KEY" http://127.0.0.1:8080/events/stream
event: ready                channels=[autopwn:events, …:scan, …:session]
event: run.started          playbook=web_vuln_chain  target=172.18.0.12
event: step.running         step=port_check
event: step.succeeded       step=port_check       summary=1 host, 1 open port (8983/tcp)
event: step.running         step=nuclei_web
event: tool.progress        (nuclei stdout 라이브 스트림)
event: step.succeeded       step=nuclei_web       summary=0 finding(s) (crit=0, high=0)
event: step.skipped         step=nuclei_followup  summary=when=false
event: run.succeeded
 
event: heartbeat            (15초마다 — autopwn API 가 보내는 SSE keepalive,
                             실제 활동 아님)

총 캡처 이벤트 수:

grep -c '^event:' /tmp/autopwn-sse.log
# 68
이벤트 타입횟수
heartbeat60
step.running2
step.succeeded2
step.skipped1
run.started1
run.succeeded1
ready1

heartbeat 15초 간격 × 60 = 900초 = 15분. 일치.


⏱️ 15분이 긴가? — Tor 회선의 대가

[1  00:35:09] state=running
[2  00:35:24] state=running
...
[59 00:49:41] state=running
[60 00:49:56] state=succeeded

-sT --top-ports 1000 -sV 면 nmap 이 1000개 포트에 connect 시도하고 열린 포트는 -sV 로 service detection 까지 한다. 일반 회선이면 10~30초인 스캔이 모든 TCP connect 가 Tor SOCKS5 회선을 새로 잡아서 15분이 됐다.

이건 OPSEC 우선 결정의 정확한 비용:

  • ✅ outbound IP 누설 0
  • ✅ DNS 누설 0 (proxychains proxy_dns 옵션)
  • ❌ 스캔 속도가 회선 대신 Tor 의 동시 회선 한계에 묶임
  • ❌ 일부 포트는 Tor exit 노드 응답 패턴이 다를 수 있어 false negative 가능

자가 호스팅 도구의 디폴트로는 받아들일 만한 트레이드오프. 속도가 필요한 engagement 는 별도 PROXY_CHAIN=false 오버라이드 (그래도 scope 안에 있어야 함)로 전환.


🐛 발견된 버그 — nmap adapter 의 결과 파싱

성공은 했는데 두 가지가 어긋났다.

증상 ① vuln_hints 가 skipped

discover summary 가 분명히:

"1 host(s), 8 open ports [proxied/connect-only]"

라고 했는데 vuln_hints 의 when 조건:

"len(ctx['results']['discover']['open_ports']) > 0"

false 로 평가됨 → summary: "when=false" 로 skip.

adapter 가 ctx 에 open_ports 리스트를 채우지 않았다. summary 문자열 한 줄만 채우고 구조화된 결과(open_ports: [22, 80, ...])는 빠짐.

증상 ② findings 가 비었음

curl -s -H "X-API-Key:$KEY" "http://127.0.0.1:8080/findings?run_id=$RUN_ID" | jq .
# []

8개 포트를 발견했는데 Finding row 가 0건. adapter 가 nmap XML 결과를 파싱해서 Finding 으로 등록하는 단계가 빠져있다.

원인 위치

api/app/tools/nmap.py 의 어댑터가:

  1. nmap -oX - 로 XML 결과를 받음
  2. summary 문자열만 만들고 반환
  3. XML 파싱 → open_ports: List[int] 채움 → Finding row 등록 이 세 줄이 미구현

다음 세션 일감으로 추가:

Task #5 — nmap adapter: open_ports 파싱 / Finding 등록
- ctx['results'][step_id]['open_ports']: list[int]
- ctx['results'][step_id]['open_ports_csv']: str (vuln_hints 의 -p 인자용)
- Finding(host, port, service, banner) row 등록

플레이북의 when 조건과 후속 step의 -p {{ results.discover.open_ports_csv }} 가 의도대로 작동하려면 adapter 측에서 이 두 키를 채워야 한다.


✅ Phase 1 검증 매트릭스

검증 항목결과
infra up (tor/pg/redis/msf/api/worker)✅ (msf 엔트리포인트 1회 패치)
Tor egress (check.torproject.org IsTor:true)
API /healthz{"ok":true}
/modules/manifest (20 endpoints, 17 events)
scope guard deny (8.8.8.8)allowed:false
scope guard allow (등록 호스트)
/playbooks/run queueing
SSE 라이브 흐름 (ready→started→running→succeeded)✅ 68 이벤트
recon_basic 완료✅ 15분, 8 open ports detected
nmap adapter 결과 파싱⚠️ 후속 일감 (open_ports + findings)
readyz tor 체크 (httpx socks5h)⚠️ cosmetic — entrypoint check 는 정상

Phase 1 백엔드 골격 PASS. 하드코어 익스플로잇이 아니라 풀 스택의 뼈대가 정상 동작하는지 확인하는 단계라 이 매트릭스로 충분.


🚀 다음 단계

  • Phase 2 — homepage /playground/hacking/autopwn: Next.js 어드민 콘솔. EventSource SSE 라이브 뷰 + 플레이북 트리거 UI + findings 표. 가이드는 docs/integration-homepage.md 에 있어서 그대로 따라가면 됨
  • Phase 3 — Zino Android :modules:autopwn: Compose UI + OkHttp SSE. 가이드는 docs/integration-zino.md
  • Phase 4 — AI escalation 토글 on: docker compose --profile ai up -d hexstrike. web_vuln_chain 만 우선 허용 (C2 플레이북은 disallow_ai: true 로 차단된 채 유지)
  • Cleanup #5 — nmap adapter 결과 파싱: 위에서 발견된 두 어긋남 패치

📝 정리

  • scope guard 가 의도대로 갈라줌 — 외부 IP 는 진입 자체가 막힘
  • SSE 채널이 의도대로 동작 — 운영자가 실시간 흐름을 모니터 가능
  • Tor 회선이 정상 동작 — IsTor:true + 15분 스캔이 동시 회선 한계로 묶인 것까지 확인
  • nmap adapter 의 ctx 채움 + Finding 등록이 미구현 — 다음 어댑터 추가 시 같은 함정 피하려면 베이스 어댑터에 helper 메서드를 만들어 두는 게 좋겠다

3편 시리즈는 여기서 마무리. 다음 일감은 Phase 2 (homepage admin 콘솔) — 그쪽으로 넘어가면 또 다른 종류의 함정이 나올 거고, 그 때 다시 ④편으로 잇는다.

ShareX

이 글이 도움이 됐나요?