머니 패스 하드닝: KVR 정산의 트랜잭션 보안
결제 서명 재사용, 무인증 보상 발행, 레이스 이중지불 — 세 가지 실제 취약점 클래스를 게이트웨이 정산 서비스에서 찾아 익스플로잇 테스트로 입증하고 봉쇄했다.
DePIN에서 머니 패스는 컴퓨트 패스만큼 적대적이다: KVR을 적립해 주는 모든 엔드포인트는 결국, 일하지 않고 KVR을 원하는 누군가에게 찔려 본다. 온체인 결제를 검증하고 스테이킹·노드 보상·추론 요금을 적립하는 게이트웨이 정산 서비스에 대한 보안 점검에서 세 가지 실제 취약점 클래스를 찾아 봉쇄했다. 각각 수정 전 익스플로잇 스타일 테스트로 입증하고, 수정 후 재검증했다.
클라이언트의 주장이 아니라 온체인 사실을 검증한다
Kvasir의 수탁 모델은 키를 사용자에게 둔다: 지갑이 트랜잭션에 서명하고, Solana가 이를 기록하며, 정산 서비스의 일은 잔액을 건드리기 전에 체인에서 실제로 일어난 일을 검증하는 것이다. 결제는 *quote → payment → inference*를 따르고, 소비된 모든 트랜잭션 서명은 일회용 usedSignatures 레지스트리에 기록돼 두 번 제시될 수 없다. 그래서 정산 서비스가 관문이 되고 — 절대 어겨선 안 되는 규칙은 하나다: 체인이 증명한 것만 적립하고, 클라이언트가 주장한 것은 절대 적립하지 않는다. 다만 이 서비스는 devnet에서 자금을 보관한다: 온체인 스테이킹 프로그램이 출시될 때까지 스테이킹된 KVR과 선불 크레딧은 서비스의 트레저리에 있고 원장으로 관리된다.

결제를 지불자에게 묶는다
Solana 서명은 공개다. 스테이킹 검증 경로는 vault가 기대한 KVR을 *받았는지*만 확인했고 — *누가 보냈는지*는 확인하지 않았다. 공격자는 devnet에서 피해자의 KVR→vault 이체를 지켜보다가 {owner: 공격자, signature: 피해자의 것}을 제출하면 됐다: vault 수신 검사는 통과했고, 원금은 공격자에게 적립됐으며, unstake 한 번이면 자금은 공격자의 것이었다. 블록 익스플로러만으로 가능한 직접 절도다.
수정: KVR이 적립 대상 owner 소유의 토큰 계정에서 출금됐어야 한다.
verifyStakeTransfer(signature, owner, amount):
delta(vault) >= amount # vault actually received it (old check)
Σ debits from token accounts
whose owner == credited owner # NEW — sender binding
>= amount # summed across that owner's accounts
# inference path (no owner): bound by private requestId
# + one-shot usedSignatures instead보상은 인증된 출처에서만
노드 보상 엔드포인트들은 자가 신고 입력으로 청구 가능한 KVR을 발행하고 있었다: POST /api/node/contribution은 클라이언트가 주장한 units를 그대로 적립했고 — units: 1e9 한 번과 claim 호출이면 vault를 비울 수 있었다 — register/heartbeat는 자칭 허브/게이트웨이 역할(시간당 인프라 보상)과 성능 점수(보상 배수)를 그대로 믿었다. 수정은 보상에 영향을 주는 모든 주장을 신뢰된 리포터 뒤로 게이트한다: 허브의 기여 폴링이 쓰는 M2M 서비스 토큰 또는 인증된 관리자만이 units·인프라 역할·성능 등급을 주장할 수 있다 — 이들은 KVR을 발행하므로 open LAN 모드에서도 강제된다. 토큰 비교는 상수 시간이고, 지갑↔노드 연결은 여전히 자유롭다 — 자기 보상을 스스로 주장할 수 없을 뿐이다.
모든 잔액에 쓰는 자는 하나
정산 상태는 락 없는 read-modify-write였고, 모든 머니 연산은 중간에 온체인 지급이나 검증을 *await*한다 — 낡은 잔액을 든 채 이벤트 루프를 양보하는 것이다. 동시 claim 두 건이 같은 100 KVR 잔액을 읽고 둘 다 지급할 수 있었다. 이론이 아니었다: 익스플로잇 테스트에서 동시 claim 3건이 100 잔액에 대해 300을 지급했다.
키별 비동기 직렬화: 같은 키의 머니 연산은 엄격히 순차 실행된다.
withLock(key, fn) # per-key promise chain, self-cleaning map stake / unstake / claim → keyed by owner inference settlement → keyed by requestId inside the lock: usedSignatures check + credit # no same-signature double-credit pay out FIRST, then debit # failed payout leaves balance intact
각 계층의 현재 상태
| 계층 | 메커니즘 |
|---|---|
| 신원 | 서버 nonce에 대한 SIWS 지갑 서명 로그인 + TOTP 2FA + 일회용 백업 코드 |
| 전송 | 샤드 다운로드의 지갑 파생 node token; 릴레이의 빌드 핑거프린트 정합 |
| 결제 | 스테이킹 이체의 발신자 바인딩; 일회용 usedSignatures; 추론의 비공개 requestId |
| 정산 | 모든 잔액 쓰기를 감싸는 키별 락; 선지급-후차감; 멱등 재제출 |
| 리포팅 | 보상 관련 사실은 M2M 서비스 토큰 또는 관리자만, 상수 시간 비교 |
| 수탁 | 비수탁형 지갑 — 서비스는 vault가 가진 것만 움직일 수 있고, 사용자 키는 절대 못 만진다 |
모든 수정에 자체 익스플로잇 테스트가 딸려 있다
- 피해자의 이체 서명을 공격자 지갑으로 재사용하는 시도는 이제 거부된다("not sent by owner"); 정상 스테이킹·초과 청구·추론 결제는 변함없이 동작한다.
- 한 잔액에 대한 동시 claim 3건은 정확히 한 번 지급되고, 이미 지급된 추론의 재제출은 멱등하게 같은 결과를 돌려준다.
- 무인증 클라이언트의 자칭
units·허브/게이트웨이 역할·성능 등급은 이제 보상을 1 lamport도 움직이지 못한다.
세 수정의 관통선은 하나의 원칙을 세 방향으로 적용한 것이다: 체인이 진실의 원천이고, 서비스는 검증자이며, 모든 잔액에는 정확히 하나의 쓰기 주체가 있다. 정산 서비스는 아직 Solana devnet에서 돈다 — 메인넷이 판돈을 올리기 전에 이런 클래스들을 찾아 익스플로잇하고 고치기에 정확히 알맞은 곳이다.