시리즈: 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| 이벤트 타입 | 횟수 |
|---|---|
heartbeat | 60 |
step.running | 2 |
step.succeeded | 2 |
step.skipped | 1 |
run.started | 1 |
run.succeeded | 1 |
ready | 1 |
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 의 어댑터가:
nmap -oX -로 XML 결과를 받음- summary 문자열만 만들고 반환
- 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 콘솔) — 그쪽으로 넘어가면 또 다른 종류의 함정이 나올 거고, 그 때 다시 ④편으로 잇는다.



