payTo 주소 변경이 허니팟(honeypot)은 아니다: 272개의 payTo 변경, 13개의 유사 사례, 제로 증명
요약
x402 결제 프로토콜에서 payTo 주소 변경을 허니팟으로 오판하여 발생한 탐지 오류 사례를 분석합니다. 크롤링 기반의 평판 시스템이 실제 인프라 기업의 정상적인 주소 변경을 공격으로 잘못 분류한 기술적 한계를 다룹니다.
핵심 포인트
- payTo 주소 변경을 무조건적인 허니팟으로 간주하는 로직의 위험성
- 크롤링 기반 스냅샷 데이터와 실제 API 호출 간의 정보 불일치 문제
- 잘못된 평판 레이블이 시스템 신뢰도에 미치는 영향
- 동적 주소 스왑 감지 시 발생할 수 있는 오탐(False Positive) 사례
지난 게시물(last post)에서 x402 결제가 잘못될 수 있는 방법들을 나열했습니다. 첫 번째 벡터는 **동적 payTo 스왑(dynamic payTo swap)**이었습니다. x402에서는 목적지 주소가 요청마다 변경될 수 있어, 판매자가 당신에게 주소 A를 제시했다가 실제로는 주소 B로 결제를 받을 수 있습니다. 저는 이것을 검사할 수 있는 항목 중 하나라고 언급했습니다.
그래서 제가 이 검사를 구축하여 Frisk의 호스팅된 평판(reputation) 측에 연결하고, x402 Bazaar — 결제 엔드포인트의 공개 디렉토리 — 를 매일 크롤링하도록 했습니다. 아이디어는 간단했습니다. 각 엔드포인트가 광고하는 payTo를 기억하고, 만약 그것이 변경되면 이는 스왑(swap)이므로 거래 상대방을 플래그(flag)하는 것입니다.
한 달 동안 약 272개의 payTo 변경을 246개 엔드포인트에서 기록했으며, 이를 57개의 honeypot:payto_swap 평판 레이블로 승격시켰습니다.
그런 다음 실제로 무엇을 플래그했는지 살펴보았습니다. 전체 데이터셋에서 가장 많은 플래그를 받은 엔드포인트는 9개인 x402.browserbase.com이었습니다. 두 번째는 8개인 x402.tavily.com이었습니다. Browserbase와 Tavily는 실제 자금 지원을 받는 인프라 회사입니다. 제 감지기는 이들을 점수 0점으로 평가하고 호출자들에게 허니팟이라고 알려주었습니다.
그 플래그들은 틀렸습니다. 나머지들도 마찬가지였습니다. 인정하기가 더 어려웠던 것은, 그것들이 다섯 가지 별개의 방식으로 틀렸다는 것이었고, 그중 단 하나만이 제가 실수하고 있다고 생각했던 오류였습니다.
레이블이 실제로 의미하는 것
변명에 앞서, 정확한 피해를 말씀드리겠습니다. honeypot:payto_swap 레이블은 신뢰 점수를 0으로 낮추고 high 심각도(severity) 신호를 발생시킵니다. 기본 엄격도(default strictness)인 0.3에서는 review 판정을 내리고, 엄격한 호출자나 1000을 초과하는 모든 결제에 대해서는 block으로 상향 조정됩니다. 따라서 Frisk가 문자 그대로
그 무엇도 그것들을 조사하지 않았습니다. 이 라벨들은 공개 디렉터리를 수동적으로 매일 크롤링(crawl)하여 얻은 것이었습니다. 스냅샷 작업이 능동적 조사(active probe)의 언어를 빌려 쓰고 있었기 때문에, API는 호출자들에게 자신이 한 번도 건드리지 않은 무언가를 테스트했다고 말하고 있었던 것입니다. 이 부분이 제가 글로 옮기기 가장 어려운 대목입니다.
이 상황이 단순한 망신을 넘어 재앙이 되지 않도록 막아주는 두 가지 요소가 있습니다. 첫째, 호스팅된 API에는 세 개의 키가 발급되었고 그중 하나만 활성화되어 있어, 거의 아무도 해당 라벨을 읽을 수 없었다는 점입니다. 둘째, 대부분의 사람들이 실제로 사용하는 로컬 MIT 라이선스 스크리닝 라이브러리(screening library)에는 이 버그가 전혀 없었는데, 이는 해당 라이브러리가 타인의 주소를 전혀 보지 않기 때문입니다.
순환 크롤링(rotating crawl)이 실제로 볼 수 있는 것
결함이 있는 전체 아이디어를 한 줄로 요약하면 다음과 같습니다: 주소가 변경되었다, 그러므로 무언가 잘못되었다.
크롤러가 볼 수 있는 것부터 시작해 보겠습니다. 크롤러는 하루에 한 번 실행되며 8,000개의 리스팅(listings)이 포함된 순환 윈도우(rotating window)를 탐색합니다. 저는 이것을 "일일 스냅샷(daily snapshot)"이라고 설명했는데, 이는 두 가지 측면에서 틀린 표현이었습니다. 레지스트리(registry)는 고정된 크기가 아닙니다. 제가 시작한 이후 제 테이블에는 41,286개의 엔드포인트(endpoint) URL이 축적되었고 절대 삭제되지 않는 반면, 라이브 디렉터리는 오늘 총 14,345개 정도를 보고하며 추출 시점에 따라 몇 개씩 차이가 납니다. 이 정도 규모의 레지스트리에서 엔드포인트는 매일이 아니라 약 3~5일마다 한 번씩 돌아오며, 꼬리 부분(tail)은 더 길어집니다.
상황은 이보다 더 심각합니다. 이 글을 쓰는 동안 크롤링 커서(crawl cursor)를 확인해 보니 2026-07-23부터 멈춰 있었습니다. 레지스트리가 저장된 오프셋(offset)보다 작아졌기 때문에, 매 실행마다 빈 윈도우를 가져왔고 커서를 되감지(rewind)한 채 조기에 종료되었습니다. 재개(resume) 로직이 크기가 작아지는 레지스트리를 처리할 수 없었기 때문에, 데이터 수집이 이루어지지 않은 채 3일 동안 조용히 방치되었습니다. 이 문제는 이제 수정되었습니다(빈 윈도우가 발견되면 0으로 되감깁니다). 하지만 증거가 방문 주기(visit cadence)에 의존하는 탐지기(detector)는 조용히 방문을 하지 않고 있었던 셈입니다.
따라서 크롤러가 한 번의 방문에서는 주소 A를 보고 다음 방문에서는 주소 B를 본다면, 크롤러가 알 수 있는 것은 며칠간의 간격 사이에 A -> B가 발생했다는 사실뿐입니다. 이 사실은 다음 중 어떤 경우라도 동일합니다:
- 정직한 운영자가 정해진 일정에 따라 수취 지갑 (receiving wallet)을 교체했거나, 전체 플릿 (fleet)을 새로운 금고 주소 (treasury address)로 이전했거나,
- 공격자가 자신이 제어하는 지갑으로 조용히 교체했거나.
며칠 간격으로 관찰된 A -> B라는 사실만으로는 이 둘을 구분할 수 있는 것이 아무것도 없습니다. 주소 변경은 사기의 증거가 아닙니다. 그것은 단지 주소 변경의 증거일 뿐입니다.
| 횟수 | |
|---|---|
| 기록된 payTo 변경 횟수 | 272 |
| ... |
벤더들이 9번을 교체한 것이 아닙니다. 제가 9번을 샘플링한 것입니다.
가장 먼저 무너진 것은 제가 직접 뽑았던 헤드라인 수치였습니다.
Browserbase의 엔드포인트 (endpoint)에서는 9번의 변경이 기록되었고, Tavily는 8번이 기록되었습니다. 하지만 행을 정렬해 보았을 때, 어떤 주소도 두 번 나타나지 않았습니다. Browserbase의 체인은 해당 9번의 변경 과정에서 10개의 서로 다른 주소를 거쳤으며, Tavily는 9개의 주소를 거쳤습니다. 단 한 번의 중복도, 이전 주소로의 복귀도 없었습니다.
이것은 9번을 교체한 벤더가 아닙니다. 이것은 우연히 9번을 확인한 크롤러 (crawler)의 측정 기준에 따라, 제가 확인하는 빈도만큼이나 자주 주소가 바뀌는 벤더입니다. 저의 "교체 횟수 (rotation count)"는 제 크론 스케줄 (cron schedule)에 관한 사실이었을 뿐입니다. 또한 "가장 심각한 두 명의 위반자"라는 표현도 그 자체로 틀렸습니다. 단순 플래그 (flag) 횟수로 따지면 이들은 109개, 28개, 21개, 15개의 행을 기록한 호스트들 뒤를 이어 5위와 6위에 머물러 있습니다. 이들이 목록 상단에 있는 이유는 오직 "엔드포인트당 (per endpoint)": 각 엔드포인트가 매 방문 시마다 변경되었기 때문입니다.
이 현상은 실시간으로 관찰할 수 있습니다. 이 글을 쓰는 동안 약 3시간 동안 공개 디스커버리 API (public discovery API)를 세 번 호출해 본 결과, 각 벤더는 매번 다른 Base 주소를 광고했습니다:
x402.tavily.com 0x2a09d4fd…fa09a -> 0x68459d07…091af -> 0x043d2e40…29c7
x402.browserbase.com 0x61ce240e…0633e -> 0x100f307A…13834 -> 0xC09B2303…be12
70초 간격으로 두 번 호출했을 때는 동일한 주소가 반환되었습니다. 따라서 이것은 디렉토리에서 확인할 수 있는 요청당 민팅 (per-request minting)은 아니지만, 3~5일마다 돌아오는 크롤러보다는 훨씬 빠른 속도임은 분명합니다.
Tavily 또한 주의 깊게 읽어볼 만한 내용을 게시했습니다. 해당 리스팅에는 두 가지 레일 (rails)이 인용되어 있는데, 두 번째 것은 주소 자체가 아닙니다:
eip155:8453 0x043d2e40ced48199415f3b2463d0bfab4fc029c7
aws:base urn:x402:agent-pay:see-quote
이는 _견적을 요청할 때 주소가 결정된다_는 의미를 가진 플레이스홀더 (placeholder)입니다. 이 부분은 정확히 짚고 넘어가고 싶은데, 이 포스트의 이전 초안에서 제가 잘못 파악했기 때문입니다. Tavily는 표준 EVM 레일 상에서는 구체적인 Base 주소를 광고하고 있으며, 제 파이프라인 (pipeline)이 읽는 주소도 바로 그 주소입니다. 플레이스홀더는 별도의 레일에 위치합니다. 하지만 이는 여전히 운영자가 결제 주소가 고정된 식별자 (stable identifier)가 아님을 공개적으로 밝히고 있는 것입니다.
결제당 새 주소 사용 (Fresh-address-per-payment)은 의도적인 설계입니다. 그 동기가 무엇이든 간에 이는 태만과는 정반대되는 행위이며, "주소는 동일하게 유지되어야 한다"라는 전제 위에 구축된 평판 시스템 (reputation system)은 가장 정교하게 구축된 엔드포인트 (endpoints)를 가장 위험한 것으로 선언하게 만듭니다. 이는 실패 모드 (failure mode)가 정확히 거꾸로 뒤집힌 사례입니다.
내 증거의 40%는 단 하나의 이벤트였다
272건의 변경 사항 중, 109건은 단 하루 동안 단일 호스트 (single host)에서 발생했으며, 그들 모두 기존의 동일한 주소에서 동일한 새 주소로 이동한 것이었습니다. 한 운영자가 자신의 자금고 (treasury)를 옮겼고, 제 탐지기 (detector)는 이를 109번 기록한 뒤, 마치 109개의 엔드포인트가 독립적으로 악성으로 변한 것처럼 평판 라벨 (reputation label)을 작성했습니다. 그다음으로 큰 클러스터 (cluster)는 27건으로, 이 역시 단일 호스트, 단일 날짜의 사례였습니다.
하나의 비즈니스 결정을 109번 카운트하는 방식이 무시무시해 보이는 숫자를 만들어냅니다. 이는 아주 간단히 해결할 수 있습니다. 한 호스트 아래에 있는 3개 이상의 엔드포인트가 동일한 이동을 수행한다면, 이제 이를 단일 관측치 (single observation)로 통합하면 됩니다.
공격처럼 보였던 13건
전체 데이터셋이 모두 무결하다고 주장할 수는 없습니다. 13건의 변경 사항은 일반적인 순환 (rotation)이 아니었기 때문입니다. 이들은 한 운영자에게 속해 있었으며 — 단일 게이트웨이 (gateway)의 4개 서브도메인 (subdomains)에 걸쳐 있는 10개의 엔드포인트 — 각각 단 한 글자를 제외하고는 동일한 두 주소 사이를 이동했습니다:
0xb3c2776ce3f99…942ab6
0xb3c2776ce4f99…942ab6
0x를 포함하여 총 42자이며, 40개의 16진수(hex digits) 중 39개가 일치합니다. 앞부분과 뒷부분의 문자가 동일하며, 중간의 숫자 하나만 다릅니다. 이것은 복사-붙여넣기 오염 (copy-paste-poisoning)의 형태입니다. 즉, 양 끝을 눈으로 훑어보는 사람이 확인을 해버리도록 교묘하게 바뀐 쌍둥이 주소이며, 이것이 바로 제가 만든 유사 주소 (lookalike) 분류기가 잡아내기 위해 설계된 바로 그 형태입니다.
하지만 조사 결과, 이것이 해당 사례일 가능성은 불가능합니다.
주소 오염 (Address poisoning)은 베니티 주소 (vanity address)를 채굴하는 방식으로 작동합니다. 즉, 타겟 주소의 처음과 마지막 몇 글자가 일치하는 주소가 나올 때까지 키 쌍 (keypairs)을 생성하는 것입니다. 6글자를 맞추는 것은 비용이 적게 들지만, 40개 중 39개를 맞추는 것은 그렇지 않습니다. 그러기 위해서는 약 2^156개의 키를 탐색해야 하는 수준입니다. 아무도 첫 번째 주소와 닮게 만들기 위해 두 번째 주소를 채굴하지 않았습니다. 같은 이유로, 이것은 한 운영자가 자신이 제어하는 두 개의 유사 지갑 사이를 의도적으로 번갈아 사용하는 것도 아닙니다. 누구도 그런 쌍을 의도적으로 생성할 수 없기 때문입니다. 두 주소 모두 컨트랙트 코드(contract code)가 없는 일반적인 외부 소유 계정 (externally-owned accounts, EOA)이므로, CREATE2 솔트 그라인딩 (salt-grinding)을 통해 생성된 것도 아닙니다. 그 그라인딩 역시 동일하게 2^156의 비용이 듭니다.
데이터 행들은 교체 (swap)를 나타내지도 않습니다. 10개의 엔드포인트가 …ce3…에서 …ce4…로 이동했고, 그 후 3개가 다시 돌아왔습니다. 현재 게이트웨이는 두 주소를 나란히 서비스하고 있습니다. 1,000개의 리스팅이 있는 한 페이지에서 첫 번째 주소를 가리키는 것이 22개, 두 번째 주소를 가리키는 것이 6개로 집계되었습니다. 이는 두 가지 배포 설정 (deployment configurations)이 순차적으로 도입되고 제거되는 과정이며, 그중 하나는 거의 확실하게 오타를 포함하고 있습니다. 즉, 3 대신 4를 누른 손가락 실수 같은 것입니다.
Base 네트워크에서 첫 번째 주소는 102.19 USDC를 보유하고 있고 두 번째 주소는 1.32 USDC를 보유하고 있으며, 두 주소 모두 트랜잭션을 발생시킨 적이 없습니다. 마지막 부분은 들리는 것만큼 결정적인 증거는 아닙니다. x402의 exact 스킴은 EIP-3009의 transferWithAuthorization을 통해 USDC를 정산하므로, 수신 주소는 무엇도 보낼 필요가 없으며, 논스 (nonce)가 움직이지 않고도 릴레이된 서명 (relayed signature)을 통해 두 잔액 중 어느 것이든 쓸어 담을 수 있기 때문입니다. 두 번째 주소의 키를 누군가 보유하고 있는지는 외부에서 확인할 수 없는 사항입니다.
따라서 이 배치에서 가장 무섭게 보이는 플래그는 허니팟(honeypot)도, 포이즈닝(poisoning) 시도도 아니며, 아마도 적대적(adversarial)인 의도도 전혀 없을 것입니다. 제 탐지기(detector)에서 가장 경고 수준이 높은 클래스가 가장 강력하게 작동한 대상은 설정 오류(config typo)로 보이는 것이었습니다. 만약 제가 그 라벨을 그대로 배포했다면, 실제로는 단순히 숫자 하나가 잘못 입력된 것뿐인 운영자를 상대로 사기 혐의를 제기했을 것입니다.
그리고 오탐(false positive) 중 하나는 오직 저만의 실수였습니다
네 번째 실패는 데이터에 있었던 것이 아닙니다. 제 파서(parser)에 있었습니다.
x402 리스팅은 여러 체인에서 동일한 리소스를 인용하며, 각 인용에는 고유한 payTo가 포함되어 있습니다. 제가 샘플링한 페이지에서는 1,000개의 리스팅 중 443개가 동시에 둘 이상의 서로 다른 결제 주소를 광고하고 있었습니다. 이 비율은 데이터를 가져올 때마다 달라지며, 처음 5페이지 전체를 보면 약 3분의 1에 가깝습니다. 거의 항상 그것은 EVM 주소와 Solana 주소의 조합이었습니다. 하나의 리스팅에 세 개의 서로 다른 EVM 주소가 나타나는 경우는 1,000개 중 두 번 있었습니다. 저는 accepts[0]에서 가져온, 엔드포인트당 단 하나의 counterparty만을 저장하고 있었습니다.
결과적으로 "엔드포인트의 payTo"는 결코 명확하게 정의된 값이 아니었으며, 제가 기록한 값은 레지스트리(registry)가 우연히 반환하는 순서에 따라 달라졌습니다. 결제 대상이 누구인지는 전혀 바꾸지 않은 채 배열의 순서만 바꾼 리스팅이 제 데이터베이스에는 결제 주소 교체(payment address swap)로 나타나게 된 것입니다.
여기서 저는 이전의 수정을 다시 바로잡아야 합니다. 제 첫 번째 직관은 이로 인해 272개 중 몇 개가 발생했는지 알 수 없다고 말하는 것이었습니다. 하지만 대략적으로 알 수 있으며, 그 답은 _거의 없다_입니다. 272개의 변경 사항은 모두 EVM에서 EVM으로의 변경이며, 두 개의 흔한 경로(rails) 사이의 순서 변경은 EVM에서 Solana로의 전환으로 나타나야 하는데, 그런 사례는 0건이었습니다. 1,000개 중 단 세 개의 리스팅만이 두 개의 서로 다른 EVM 주소를 포함하고 있었습니다. 이것은 원인이 아니라 잠재적인 지뢰였으며, 저는 수치를 보는 대신 코드를 읽음으로써 이를 발견했습니다. 이는 그 자체로 하나의 교훈입니다.
해결책은 지루할 정도로 간단합니다. 위치(positional) 기반이 아닌 결정론적(deterministic) 방식으로 선택하는 것입니다.
// 여러 체인에서 인용된 리스팅(listing)은 여러 개의 payTo 값을 가집니다.
// accepts[0]를 취하는 것은 우리가 기록하는 주소가 레지스트리(registry)가
// 반환하는 순서에 의존하게 만듭니다. 따라서 상위 단계에서의 순서 변경은
// payTo 변경으로 해석됩니다. 가장 낮은 값을 선택하십시오.
...
내가 스왑(swap)으로 착각했던 신호 — 그리고 그것이 어떻게 망가져 있었는지
실제적인 동적 payTo 공격(dynamic-payTo attack)은 존재하며, 이는 결제하기 전에 실제로 포착할 수 있습니다. 저는 라벨을 잘못된 관찰 대상에 붙였습니다.
진짜 공격은 _단일 요청 버스트(burst of requests) 내부_에서 발생합니다. 이는 엔드포인트(endpoint)가 동일한 순간에 서로 다른 호출자에게 서로 다른 주소를 제공할 때 발생합니다. 즉, 탐색 호출(discovery call) 시에는 주소 A를 인용하고, 1초 후 결제 요구(payment requirement) 시에는 주소 B를 반환하는 방식입니다. 이를 포착하려면 한 번의 버스트 내에서 동일한 엔드포인트를 여러 번 조사(probing)하여, 동일한 레일(rail)에 대해 두 개의 주소를 제공하는 경우가 있는지 확인하면 됩니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기