레드팀 · AI 자동화 · 시스템 엔지니어링
직접 만들고, 실험하고, 기록한다
© 2026 ZINO LAB
autopwn 개발기 ④ — Homepage 어드민 콘솔과 AI 게이트의 3중 잠금 — ZINO
2026-05-17 · 1분 읽기 ·
Phase 1 백엔드 위에 zino.kr 어드민 콘솔(4탭 + SSE 라이브뷰)을 얹고, AI escalation을 안전하게 켜는 3중 잠금 (per-run opt-in · per-playbook disallow · scope 재검증)을 코드로 강제. nmap adapter의 결과 파싱 누락도 같이 패치.
시리즈 : autopwn 개발기 ④
이전 글 : ③ 첫 라이브 런, SSE 67줄, 그리고 남은 nmap 버그
이 글의 범위 : Phase 2 (homepage admin 4탭) + Phase 4 (AI escalation 토글) + Phase 1 트레일에 남았던 nmap adapter 정리.
🧭 어디까지 왔나
Phase 상태 비고 1 — 백엔드 (FastAPI + 어댑터 + scope + SSE)✅ 검증 끝 2 — homepage admin 콘솔 (Next.js)✅ 이 글에서 다룸 3 — Zino Android :modules:autopwn ⏸️ 폰 무선 디버깅 복구되면 4 — AI escalation 토글✅ 가드 3겹
Zino 앱은 폰 쪽 디버깅 회복되면 그때. autopwn API 계약은 docs/integration-zino.md 에 동결돼 있어서 백엔드는 신경 안 써도 됨.
🧩 Phase 2 설계 — 두 라우트, 한 규칙
/playground/hacking/autopwn 에 어드민 콘솔을 깐다. 핵심은 브라우저가 autopwn API를 직접 보면 안 된다 는 룰. autopwn은 127.0.0.1:8080 loopback only 라 외부에선 못 들어오지만, 그건 인프라 가드일 뿐 — 어플리케이션 가드도 별도로 박아야 한다.
라우트 두 개
zino.kr (Next.js)
├── /api/playground/autopwn/[...path]/route.ts ← non-stream catch-all
이 글이 도움이 됐나요?
🔥 핵심 💡 인사이트 👍 좋아요 🤯 충격 ⚡ 빠름
└── /api/playground/autopwn/events/stream/ ← SSE bridge (nodejs runtime)
await requireAutopwnAdmin (); canAccessPage(session, "playground.hacking") 검사. 빠뜨리면 catastrophic — admin이 아닌 로그인 세션으로 autopwn 전체 백엔드가 노출됨. 코드 리뷰 1순위 항목.
autopwn-proxy.ts 헬퍼const BASE = process . env . AUTOPWN_BASE_URL || "http://127.0.0.1:8080" ;
const KEY = process . env . AUTOPWN_ADMIN_KEY || "" ;
export async function autopwnFetch ( path : string , init : RequestInit = {}) {
const headers = new Headers ( init . headers );
headers . set ( "X-API-Key" , KEY );
return fetch ( ` ${ BASE }${ path } ` , { ... init , headers , cache : "no-store" });
} X-API-Key 는 서버 사이드에서만 부착. 클라이언트 번들에 절대 안 들어감. devtools에서 Authorization 헤더 검사해서 키가 보이면 그게 곧 인시던트.
Hop-by-hop 제거 브라우저가 보낸 헤더를 그대로 forward 하면 위조 가능한 헤더가 끼어든다. stripHopByHop() 으로 host , x-api-key , connection , te , upgrade 등 제거 후 통과.
SSE bridge const upstream = await fetch ( ` ${ BASE } /events/stream` , {
headers : { "X-API-Key" : KEY },
signal : req . signal , // ← 클라가 닫으면 upstream도 닫힘
cache : "no-store" ,
});
return new Response ( upstream . body , {
headers : {
"Content-Type" : "text/event-stream; charset=utf-8" ,
"Cache-Control" : "no-cache, no-transform" ,
"Connection" : "keep-alive" ,
"X-Accel-Buffering" : "no" , // ← nginx/CDN 버퍼링 차단
},
});
runtime = "nodejs" — edge runtime은 SSE 호환이 약함
signal: req.signal — Node fetch 18+ 에서 클라 disconnect를 upstream으로 전파
X-Accel-Buffering — 프록시 중간에서 청크 단위로 흘려보내도록
🪟 UI — 4탭 + 라이브 사이드바 ┌───────────────────────────────────────────────────────────┐
│ autopwn SSE● open · API: v1.0 · [+ New Run] │
├───────────────────────────────────────────────────────────┤
│ Runs | Sessions | Findings | Listeners │
├──────────────────────────────────┬────────────────────────┤
│ │ Live events │
│ [tab content] │ 15:35:02 run.started │
│ │ 15:35:02 step.running │
│ │ 15:49:54 step.succ… │
└──────────────────────────────────┴────────────────────────┘ zino.kr/playground/hacking — autopwn 어드민 콘솔 실 화면. 상단 메타 (SSE open · API 0.2.0 · + New Run) + 7개 stat card (ACTIVE 1 / BLOCKED 2 / SUCCEEDED 13 / FAILED 30 / FINDINGS 14 / SESSIONS 0 / LISTENERS 1) + High severity 카운터 + LIVE PIPELINES 에 진행 중인 run (recon_basic → 1.240.148.44).
오른쪽 라이브 사이드바는 SSE 이벤트 200건 한도. heartbeat 제외하면 SWR mutate 트리거해서 4탭 SWR 캐시도 같이 갱신.
const handleEvent = ( type : string ) => ( raw : MessageEvent ) => {
const data = JSON . parse ( raw . data );
const ev = { type , ... data } as AutopwnEvent ;
setEventLog (( prev ) => [ ev , ... prev ]. slice ( 0 , 200 ));
if ( ev . type !== "heartbeat" ) {
runsSWR . mutate ();
findingsSWR . mutate ();
sessionsSWR . mutate ();
listenersSWR . mutate ();
}
if ( ev . type === "ai.suggestion" ) {
setPendingApproval ( ev . suggestion );
}
}; ai.suggestion 이벤트가 도착하면 모달이 자동으로 뜬다. 닫기/Approve/Deny 셋 중 하나를 골라야 run이 풀림.
TypeScript DTO 1:1 types.ts 가 api/app/models/schemas.py 의 BaseModel과 1:1 매칭. 어느 한쪽을 바꾸면 양쪽 동시 갱신이 룰.
export interface Finding {
id : string ;
run_id : string ;
severity : "info" | "low" | "medium" | "high" | "critical" ;
title : string ;
target : string ;
evidence : Record < string , unknown >;
cve : string [];
created_at : string ;
} OpenAPI 자동 생성 코드를 안 쓰는 이유 — 백엔드가 dict[str, Any] 같은 느슨한 타입을 쓰면 자동 생성기가 unknown 으로 만들고 UI 코드가 as 로 단언하게 됨. 그럴 바엔 손으로 1:1 매핑하는 게 안전.
🛡️ Phase 4 — AI escalation 의 3중 잠금 AI를 펜테스트 자동화에 끼우는 건 매력적인 만큼 위험도 높다. autopwn의 디자인 헌법은 "AI는 제안만, 실행은 사람 승인 후 deterministic runner". 이걸 정책 문서 아닌 코드로 박는다.
잠금 ① — 디폴트 OFF, per-run opt-in # api/app/core/settings.py
ai_escalation_default: bool = Field ( default = False , alias = "AI_ESCALATION_DEFAULT" ) 매 실행 시 ai_escalation=true 를 명시 요청해야 함. 모달의 체크박스가 그 진입.
잠금 ② — per-playbook disallow # api/playbooks/reverse_shell_drop.yaml
name : reverse_shell_drop
disallow_ai : true # ← C2/익스플로잇 플레이북은 영구 차단 if req.ai_escalation and bool (pb. get ( "disallow_ai" , False )):
raise HTTPException ( 400 , detail = "this playbook disallows AI escalation" ) UI 모달 측에서도 disallow_ai 가 true 면 체크박스가 disabled로 표시된다.
라우터 가드 실 동작 — reverse_shell_drop 에 ai_escalation:true 를 붙여 호출하면 HTTP 400 + "this playbook disallows AI escalation". yaml 의 disallow_ai: true 한 줄이 진짜로 잠근다.
잠금 ③ — 제안 도착 후 scope guard 재검증 for sug_step in suggestion.suggested_next:
scope_guard. validate_step_dict (sug_step, set ( ADAPTERS . keys ()), ctx[ "target" ]) AI가 제안한 step의 tool 이 화이트리스트(nmap , nuclei , msf.exploit , sliver.* )에 없으면 즉시 ScopeError. 타깃도 다시 scope 통과해야 됨.
세 잠금 중 하나만 풀려도 실행 안 됨. AI는 어디까지나 옵션 이라는 말이 코드로 강제됨.
실행 흐름 ai_on=true
step 실행 → 예외 발생 ─────────────────→ HexStrike /suggest
│ │
│ ai_on=false │ 제안 받음
▼ ▼
step.failed → run.failed step.blocked
│
│ 사람이 모달에서 Approve
▼
scope_guard.validate_step_dict
│
▼
deterministic runner
│
▼
step.succeeded → run.succeeded
작은 정리 — 이중 이벤트 제거 처음 구현했을 때 흐름이 step.failed → step.blocked 로 두 이벤트가 연달아 나왔다 (예외 잡고 _step 호출 후 escalation 진입). UI에선 "어 실패했네… 어 블락됐네?" 가 깜빡임. 패치:
except Exception as e:
err_msg = str (e)[: 200 ]
# Try AI escalation FIRST. Only emit step.failed when no recovery path.
ok = await _maybe_escalate (run_id, sid, pb, ctx, ai_on)
if not ok:
await _step (run_id, sid, "failed" , summary = err_msg)
await store. update_run_state (run_id, "failed" )
return AI 켜져 있고 제안이 오면 _maybe_escalate 내부에서 step.blocked 만 발행. AI가 꺼져 있거나 제안 못 받으면 step.failed.
🧹 Phase 1 트레일 — nmap adapter 정리
버그 ① — len() 도 못 쓰는 _eval_when return bool ( eval (expr, { "__builtins__" : {}}, { "ctx" : ctx})) 빈 __builtins__ 로 sandbox 만든답시고 len , any , all 같은 안전한 빌트인까지 다 날아갔다. playbook의 when: "len(ctx['results']['discover']['open_ports']) > 0" 가 NameError → except → False.
_WHEN_BUILTINS = {
"len" : len , "any" : any , "all" : all , "sum" : sum ,
"min" : min , "max" : max , "bool" : bool ,
"int" : int , "str" : str , "list" : list , "dict" : dict , "set" : set ,
}
def _eval_when ( expr : str , ctx : dict ) -> bool :
...
return bool ( eval (expr, { "__builtins__" : _WHEN_BUILTINS }, { "ctx" : ctx})) sandbox 에서 임의 코드 실행 가능성을 막아야 하지만, eval 자체가 자제할 도구라 — 더 엄격하게 가려면 AST 파서로 갈 일이고 일단 이 정도가 적절.
버그 ② — findings 가 비어 있던 이유 nmap adapter가 open_ports 리스트는 반환했는데 findings 키 자체가 없었다. runner의 finding 등록 로직도 publish만 하고 store 호출이 빠져 있음.
adapter 측에 findings 생성 추가:
findings: list[dict[ str , Any]] = []
for h in hosts:
for p in h[ "ports" ]:
title = f "Open port { p[ 'port' ] } / { p[ 'protocol' ] } ( { p[ 'service' ] } )"
findings. append ({
"severity" : "info" ,
"title" : title,
"target" : h. get ( "address" ) or target,
"evidence" : {
"port" : p[ "port" ],
"service" : p[ "service" ],
"product" : p[ "product" ],
"version" : p[ "version" ],
"via_proxy" : is_proxied (),
},
})
return { ... , "findings" : findings} runner 측에 store 등록 + 이벤트 publish:
if result. get ( "findings" ):
for raw in result[ "findings" ]:
finding = Finding (
id = uuid. uuid4 ().hex,
run_id = run_id,
target = raw. get ( "target" , target),
severity = raw. get ( "severity" , "info" ),
...
created_at = datetime. now (timezone.utc),
)
await store. append_finding (finding)
await publish ( "autopwn:events:scan" , {
"type" : "finding.new" , ...
"finding" : finding. model_dump ( mode = "json" ),
}) 이제 recon_basic 한 번 돌리면 발견된 open port 마다 severity=info finding이 Findings 탭에 뜬다. UI 측 Severity filter 도 작동.
✅ 통합 검증 매트릭스 검증 상태 메모 /api/playground/autopwn/playbooks 200⏳ 로컬 인프라 띄우면 /api/playground/autopwn/events/stream SSE OK⏳ EventSource 연결 + heartbeat 비-admin 접근 → 403 ⏳ playground.hacking ACLAI 모달 (ai.suggestion 이벤트 → 자동 팝업) ⏳ hexstrike 띄우고 web_vuln_chain 실행 reverse_shell_drop + ai_escalation=true → 400⏳ router의 disallow_ai 가드 TypeScript 컴파일 ✅ npx tsc --noEmit 0 에러
로컬 인프라(docker compose up -d ) + ./run_homepage.sh dev 띄우면 E2E 확인 가능.
🚀 다음 단계
Phase 3 재개 — Zino 폰 무선 디버깅 복구 후 /zino_module skill 호출. API 계약은 동결돼 있어 백엔드 변경 0
nmap adapter 확장 — -sV 결과의 product/version으로 vulndb 매칭(별 노력 없이) → finding severity 자동 격상
vuln_hints 후속 자동화 — open port에서 http 감지 시 nuclei 체이닝 (이미 web_vuln_chain 에 있음, 트리거만 자동화)
ai 백엔드 교체 가능성 — HexStrike 업스트림이 불안정하면 ReconLM/Claude API 등으로 교체. escalation.py 의 어댑터 함수 한 개만 갈면 됨
📝 정리
두 라우트 한 룰 — 모든 프록시 진입부에 requireAutopwnAdmin() . 빠뜨리면 catastrophic
X-API-Key 는 서버 사이드 only — 브라우저 번들에 절대 노출 X
AI 게이트 3겹 — per-run opt-in · per-playbook disallow · scope/tool 재검증. 셋 다 풀려야 실행
이벤트 일관성 — step.failed → step.blocked 깜빡임 제거, 회복 가능하면 blocked 만 발행
nmap adapter — _eval_when 의 빈 builtins 사각지대 + findings 등록 누락 패치
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…