Skip to content

feat: multi-tx 익스플로잇 후보 중 실제 drain tx 선택 (RCA 전) - #12

Merged
wiimdy merged 3 commits into
mainfrom
feat/multi-tx-drain-selection
Jun 25, 2026
Merged

wiimdy merged 3 commits into
mainfrom
feat/multi-tx-drain-selection

Conversation

@jungjipdo

@jungjipdo jungjipdo commented Jun 25, 2026 •

Copy link
Copy Markdown
Member

한 줄 요약

알림에 exploit tx가 여러 개일 때, 비싼 RCA를 돌리기 전에 후보 중 "진짜 자금 유출(drain) tx"를 싸게 골라내는 단계를 추가합니다. lumoskit 코어 변경 0, DB 마이그레이션 0.


왜 (배경)

OriginTrail 해킹 알림에는 exploit tx가 2개였습니다:

  • 0x18ccaa7b… — authority/state setup tx (자금 이동 거의 없음)
  • 0xb8e2a8f2… — 실제 27M TRAC drain tx

기존 파이프라인은 알림 텍스트에 먼저 적힌 setup tx만 분석하고 drain tx를 영구 누락했습니다(어느 게 drain인지 고르는 로직이 아예 없고, 서술 순서가 결과를 정함). 그 결과 RCA가 "이건 자산 유출이 아니라 권한 설정"이라고 나오고 경제 피해가 0으로 집계됐습니다.

"어느 tx가 진짜 drain인가"는 온체인 사실이라 텍스트 전용인 hack-detector는 판단할 수 없습니다. 그래서 역할을 나눕니다:

  • hack-detector (별도 PR hack-detector#14): 후보를 버리지 않고 최대 3개 수집해 candidate_tx_hashes[]로 전달.
  • Backlight (이 PR): 후보 중 실제 drain을 골라 그 tx로 RCA.

어떻게 동작하나 (그림)

POST /signals  { tx_hash: 0x18cc(setup), candidate_tx_hashes: [0x18cc, 0xb8e2] }
        │
        ▼  candidate_tx_hashes 를 metadata blob에 저장 (스키마 변경 없음)
   [case queued]
        │  worker가 케이스를 집어들면, 비싼 RCA 전에:
        ▼
   ┌─ 후보마다(병렬) "싼 prefix"만 실행 (LLM 없음) ────────┐
   │   lumoskit --stage flow_context_select               │
   │     = cefg → localize → lift → flow_context          │
   │   → artifacts/flow_context/fund_flows.json 의        │
   │     net_flows(지갑별 순증·순감)를 읽음                │
   │     · setup tx : net_flows ≈ []        (움직임 0)     │
   │     · drain tx : 큰 음/양 delta        (큰 움직임)    │
   └──────────────────────────────────────────────────────┘
        │  가장 큰 순유출 후보 = drain → 승자 선택
        ▼
   승자 ≠ primary 이면 case.tx_hash 재지정 + drain_tx_selected 이벤트 기록
        │
        ▼
   기존 full RCA를 승자(0xb8e2) 1개에만 실행  ← 기존 코드 그대로

비용: 후보들의 싼 prefix는 동시(병렬) 실행되어 추가 wall-clock이 N×가 아니라 ~1×prefix입니다(각 후보는 격리된 output 하위 디렉터리에 기록, Runner 무상태). 비싼 RCA는 여전히 1번(승자만) = LLM 비배수. 단일 tx 알림(후보 <2)이나 stage resume에서는 선택 단계를 건너뜀 → 기존 동작 그대로(추가비용 ≈ 메타데이터 파싱 1회, 약 4µs).


정확히 바뀌는 것 (파일별)

파일 변경 성격
internal/api/types.go SubmissionRequest에 CandidateTxHashes []string (omitempty) additive
internal/api/validation.go 후보 ≤3개 + 각 txHashRegex 검증 기존 tx_hash 검증 유지
internal/api/signals.go withCandidateTxHashes()로 후보를 기존 metadata blob에 주입 DB 마이그레이션 없음
internal/lumoskit/runner.go stages()에 "flow_context_select" → [cefg, localize, lift, flow_context] 기존 subprocess 계약(ADR-0018) 그대로
internal/store/cases.go UpdateCaseTxHash() 추가 (case를 승자 tx로 재지정) 인덱스 non-unique라 안전
internal/worker/worker.go full RCA 전에 selectDrainTxIfNeeded() 호출 (signal-context 쓰기 전) 후보 없으면 no-op
internal/worker/selector.go (신규) 선택 로직: 후보별 prefix 실행 → fund_flows 랭킹 → 승자 신규 로직 격리
internal/worker/selector_test.go (신규) 단위 + fake-binary 통합 테스트 —

lumoskit (Rust) 변경 없음. 기존 --stage + fund_flows.json만 재사용합니다.


아키텍처 경계 (리뷰 포인트)

ADR-0018대로 helios는 오케스트레이터입니다. 분석(flow_context 계산)은 lumoskit이 subprocess로 수행하고, helios는 그 산출물(fund_flows.json)을 읽어서 라우팅(어느 tx를 RCA할지)만 결정합니다. helios는 분석을 직접 하지 않습니다.

팀 검토를 바라는 결정 두 가지:

  1. 케이스 재지정(Option A): 승자가 primary와 다르면 case.tx_hash를 승자로 갱신합니다. 대안(라벨은 primary 유지, 분석만 승자)은 케이스 tx와 실제 분석이 불일치해 운영자가 혼란스럽습니다. → 재지정을 택했고, drain_tx_selected 이벤트로 원본/선택/랭킹을 남깁니다.
  2. 내부 아티팩트 읽기: 선택을 위해 artifacts/flow_context/fund_flows.json(lumoskit 내부 산출물)을 읽습니다 — 계약상 명시된 summary.json보다 한 단계 깊은 결합입니다. 더 깔끔한 대안은 lumoskit이 drain 크기를 안정 필드로 노출하는 것이나, 그건 lumoskit 코어 변경이라 보수성을 위해 보류했습니다.

랭킹 신호는 net_flows의 최대 절대 delta입니다 — USD 정밀 수치가 아니라 "drain vs setup"을 가르는 거친 판별자입니다(정밀 피해액은 승자의 full RCA가 산출). 모든 후보가 움직임 0이거나 prefix가 실패하면 primary를 그대로 유지해 오늘보다 나빠지지 않습니다.


검증

  • go build ./... + go vet ./internal/... — clean
  • 신규 테스트: TestCandidateTxHashesFromMetadata, TestReadNetFlowMagnitude, TestSelectDrainTxPicksLargestNetMovement, TestSelectDrainTxKeepsPrimaryWhenNoMovement — pass
  • internal/worker / internal/api / internal/store 스위트 — green
  • ⚠️ 기존 실패 1건: internal/lumoskit/TestRunnerUsesLumoskitRepoRootForBinaryUnderBin — macOS /private/var vs /var 심링크 이슈로, origin/main에서도 동일하게 실패(이 PR과 무관). CI(Linux)에서는 통과 예상.

성능 (검증됨)

  • 단일 tx: 추가 작업은 메타데이터 파싱 1회뿐(마이크로벤치 ~4µs). 서브프로세스·RPC·LLM 추가 0. 병목 없음.
  • 멀티 tx: 후보 prefix를 병렬 fan-out → 추가 wall-clock ~1×prefix(순차 N× 대비 개선). LLM은 승자 1회로 비배수. 선택은 후보 순서 기준 결정론(완료 순서 무관), -race 통과.

후속(별도 PR 후보)

  • 승자 prefix를 case output_root에 캐시해 full RCA가 재사용하도록(현재는 승자 prefix가 phase2에서 1회 중복 실행 — 결정론·저비용).
  • 선택 단계가 실제로 4-스테이지를 호출하는지 검증하는 통합 테스트(현재 fake binary는 --stage를 무시).

🤖 Generated with Claude Code

https://claude.ai/code/session_01K3KnRg8X2mkfPwM3LLJtys

jungjipdo and others added 2 commits June 25, 2026 13:54
When a single alert names more than one exploit tx (e.g. OriginTrail —
authority/state-setup tx + the actual asset-drain tx), hack-detector now sends
up to 3 candidate hashes (PR hack-detector#14). This teaches Backlight to pick
the real asset-drain among them before spending the expensive RCA budget.

How it works (no lumoskit core change, no DB migration):
- POST /signals accepts optional candidate_tx_hashes[] (≤3, validated). It is
  stashed inside the existing metadata blob, so the worker can read it later.
- Before the full RCA, the worker runs the cheap deterministic flow_context
  prefix (cefg→localize→lift→flow_context, no agent/LLM) on each candidate via
  the existing lumoskit subprocess contract (ADR-0018), reads each
  artifacts/flow_context/fund_flows.json, and picks the candidate with the
  largest net fund movement — the drain. A setup tx moves ~no funds.
- If the winner differs from the case's primary tx, the case is re-pointed at it
  (UpdateCaseTxHash; the (chain,tx_hash) index is non-unique and dedup already
  ran, so this is safe) and a drain_tx_selected event records the ranking.

Cost: only the cheap prefix runs per candidate; the expensive RCA still runs
once, on the winner. Single-tx submissions are completely unaffected (selection
is skipped when <2 candidates and on stage resumes).

Boundary: helios stays an orchestrator — lumoskit computes the economics
(flow_context), helios only reads the artifact and makes the routing choice.

Verified: go build ./... + go vet clean; new selector unit/integration tests
pass; worker/api/store suites green. (Pre-existing macOS-only failure
TestRunnerUsesLumoskitRepoRootForBinaryUnderBin — /private/var vs /var symlink —
also fails on origin/main and is unrelated.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3KnRg8X2mkfPwM3LLJtys
selectDrainTx fanned out each candidate's cheap flow_context prefix
sequentially (N×prefix wall-clock). Run them concurrently instead — each
candidate writes to an isolated output subdir and Runner is stateless, so
there is no contention. Selection stays deterministic: ranking is rebuilt
in candidate order and ties keep the earlier candidate, independent of
completion order. Drops added wall-clock from N×prefix to ~1×prefix; the
single expensive RCA on the winner is unchanged. Single-tx path unaffected.

Verified: go build/vet clean; selector tests (incl. new 3-candidate
order-determinism test) pass under -race.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3KnRg8X2mkfPwM3LLJtys
@jungjipdo
jungjipdo requested a review from wiimdy June 25, 2026 11:38
@wiimdy
wiimdy merged commit 52e5264 into main Jun 25, 2026
1 check passed
@wiimdy
wiimdy deleted the feat/multi-tx-drain-selection branch June 25, 2026 12:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants