보이지 않는 연결고리: 교차 기능 재진입이라는 유령 morgan021 2025. 11. 14.
> _
우리는 자동화된 시스템의 시대에 살고 있다. 코드는 법이며, 기계는 중개자 없이도 정해진 규칙에 따라 자산을 교환하고 복잡한 금융 거래를 처리한다. 이 모든 것은 참여자들이 서로를 몰라도 시스템의 논리를 신뢰할 수 있다는 전제 위에 세워졌다. 이 디지털 생태계는 레고 블록처럼, 각기 다른 기능을 가진 작은 프로그램(컨트랙트)들이 서로 연결되고 호출하며 거대한 구조물을 이룬다. 효율적이고, 빠르며, 투명하다.
하지만 이 견고해 보이는 신뢰의 탑은 때때로 너무나도 교묘한 방식으로 무너진다. 공격은 육중한 성문을 부수는 대신, 성벽을 쌓는 석공들 사이의 암묵적인 신뢰를 이용한다. 하나의 블록이 다른 블록을 호출하고, 그 블록이 또 다른 블록을 참조하는 이 복잡한 연결성 자체가 치명적인 무기가 될 수 있다.
이것은 시스템을 정면으로 돌파하는 해킹이 아니다. 이것은 시스템의 논리를 그대로 따르면서, 그 논리의 빈틈을 파고들어 모든 것을 무너뜨리는 도미노 게임이다. 오늘은 이와 관련된 교차 기능 재진입(Cross-Function Reentrancy)에 대해서 알아보자.

신뢰를 전제로 한 자동화의 시대
현대의 탈중앙화된 시스템은 모듈성에서 그 힘을 얻는다. 거대한 하나의 프로그램이 모든 일을 처리하는 대신, 잘게 쪼개진 전문 프로그램들이 각자의 임무를 수행한다. A라는 프로그램은 자산을 보관하는 금고 역할을 하고, B라는 프로그램은 현재 시장 가격을 알려주는 정보원 역할을 한다.
이 구조가 작동하려면 이들은 서로를 호출하고 그 결과를 신뢰해야 한다. 금고 A는 가격 정보원 B의 데이터를 믿어야 한다. 이러한 상호작용은 명백하고 투명하게 설계된 것처럼 보인다.
문제는 이 신뢰가 맹목적일 때 발생한다. A가 B에게 무언가를 묻기 위해 잠시 문을 열었을 때, A는 B가 정직하게 대답만 하고 돌아갈 것이라 가정한다. B가 A의 집 안을 돌아다니거나, A가 모르는 다른 존재를 집 안으로 들여보낼 수 있다는 가능성은 종종 간과된다. 이 간과된 가능성이 바로 모든 재앙의 씨앗이다.
보이지 않는 무대 위의 배우들: 금고와 공격자
이 복잡한 공격을 이해하기 위해 무대 위에 두 명의 배우를 세워보자.
첫째, '금고'라 불리는 컨트랙트 A가 있다. 이곳은 사용자의 자산이 보관되는 핵심 장소다. 이곳에는 자금을 인출하는 withdraw() 기능과 자금을 이체하는 transfer() 기능이 있다.
둘째, '공격자'라 불리는 컨트랙트 C가 있다. 이것은 공격자가 설계한 악의적인 프로그램이다. 이 '공격자 C'는 악의적인 의도를 가지고 있지만 A의 기능을 호출하는 평범한 사용자인 척하며 상호작용할 수 있다.
무심코 열어둔 문: 사소한 '외부 참조'의 위험
공격의 방아쇠는 '금고 A'의 withdraw() 기능에 있는 사소한 설계 결함에서 당겨진다. 이상적인 withdraw() 기능은 다음과 같이 작동해야 한다.
- 검사 (Checks): 인출 요청자의 잔액을 확인한다.
- 효과 (Effects): 즉시 내부 장부에서 요청자의 잔액을 0으로 변경한다.
- 상호작용 (Interactions): 요청자에게 실제 자금을 전송한다.
하지만 '금고 A'의 개발자는 이 순서를 따르지 않았다. 대신, 인출액에 따른 보너스 포인트를 계산한다는 명목으로, 내부 장부를 정리하기 전에 외부 컨트랙트(안타깝게도 공격자 C가 통제하는)를 호출한다.
금고 A의 withdraw() 논리는 이렇다.
- 잔액 100 확인. 좋아. (메모리에
보낼 돈 = 100저장) - 이제 '신뢰하는 파트너' C에게 가서 보너스 포인트가 얼마인지 물어보고 와.
- 돌아오면 장부 정리하고
- 아까 메모리에 저장한
보낼 돈100을 보내주지.
이것이 바로 '무심코 열어둔 문'이다. 금고 A는 자신의 장부(Storage)가 아직 100인 어수선한 상태에서, 포인트를 계산한 뒤 정리하겠다는 생각으로 프로그램의 제어권을 '공격자 C'라는 외부 존재에게 잠시 넘겨버렸다.
첫 번째 도미노가 넘어지는 순간
이제 공격이 시작된다.
- '공격자 C'가 '금고 A'의
withdraw(100)기능을 호출한다. - '금고 A'는 인출 요청을 확인한다. 공격자의 잔액은 100이다.
보낼 돈 = 100을 메모리에 기억한다. - '금고 A'는 장부를 0으로 바꾸는 대신, 다음 2단계에 따라 '공격자 C'의 "포인트 계산" 기능을 호출한다.
- 제어권이 '공격자 C'에게 넘어갔다. '금고 A'는
withdraw()함수의 2단계와 3단계 사이에서 '일시 정지'된다. - 이 순간, '금고 A'의 내부 장부(Storage)는 여전히 100이다.
부메랑이 된 신뢰: 시스템의 심장을 찌르다
가장 치명적인 순간이다. '공격자 C'는 제어권을 쥐고 있는 이 짧은 순간에, '금고 A'에게 받은 부탁(보너스 계산)을 수행하는 척하면서 완전히 새로운 행동을 개시한다.
그것은 '금고 A'의 다른 문(Door)을 두드리는 것이다.
'공격자 C'는 withdraw() 함수를 다시 호출하지 않는다. (withdraw 함수에 단순 재진입 방지 락(Lock)이 걸려있다는 가정) 대신, '금고 A'가 미처 잠그지 못한 transfer() 기능을 호출한다. (예: A.transfer(공격자주소, 100))
이것이 '교차 기능 재진입'이다.
'금고 A'는 이 transfer() 호출을 어떻게 받아들일까? transfer 기능에는 아무런 잠금 장치가 없었기 때문에, '금고 A'는 이 요청을 첫 번째 withdraw 요청과는 전혀 무관한, 완전히 새롭고 합법적인 작업 지시로 받아들인다.
그렇게 '금고 A'는 transfer() 요청을 받아들고 다시 잔액을 확인한다. 첫 번째 withdraw가 현재 '일시 정지' 상태이므로, 내부 장부(Storage)는 여전히 100이다.
혼돈의 몇 초: 장부가 두 번 기록될 때
- [교차 공격] '금고 A'는
transfer()요청에 따라 "잔액이 100이군"이라고 확인하고, 100의 자금을 '공격자 C'에게 전송한다. - 그리고
transfer()기능의 논리에 따라, 내부 장부(Storage)의 잔액을 100에서 0으로 변경한다. transfer()기능이 완료되었다.- [제어권 반환] 제어권은 이어서 '공격자 C'에서 '금고 A'의 '일시 정지' 지점(
withdraw()함수의 2단계)으로 돌아온다. - '금고 A'는 생각한다. "아, '공격자 C'가 드디어 포인트 계산이 끝났나봐. 이제 원래 하던 3, 4단계를 마저 해야지."
- [치명적 실행]
withdraw()함수는 3단계(장부 정리)와 4단계(자금 전송)를 실행한다. 이때, 4단계는 "아까 1단계에서 메모리에 저장해 둔 '오래된 정보'(Stale Data)인보낼 돈 = 100"에 기반하여 실행된다. withdraw()함수는 현재 Storage의 잔액(이미 0임)을 다시 묻지 않고, 메모리에 기억된 100의 자금을 '공격자 C'에게 또다시 전송한다.- 모든 것이 끝났을 때, 공격자는 100의 잔액으로 200의 자금을 인출해갔다.
'금고 A'는 모든 절차를 논리대로 수행했지만, 시스템은 파산했다. 신뢰의 연쇄가 상태의 불일치를 만들었고, 그 불일치가 자금 탈취를 가능하게 했다.
단 하나의 교훈: 자동화된 시스템의 '제로 트러스트'
이 도미노 사태는 우리에게 자동화된 시스템을 설계할 때 필요한 단 하나의, 그러나 절대적인 교훈을 남긴다. 바로 '제로 트러스트' 원칙이다.
코드가 코드를 호출할 때, 그것이 아무리 무해해 보이는 파트너일지라도, 그 파트너가 악의적이거나 혹은 그 파트너 역시 다른 악의적인 존재에 의해 감염되었을 수 있다고 가정해야 한다.
이 공격을 막는 유일한 방법은 보안의 제1원칙, '검사-효과-상호작용(Checks-Effects-Interactions)' 패턴을 따르는 것이다.
- 검사 (Checks): 인출 요청이 유효한지 확인한다. (잔액 100 확인)
- 효과 (Effects): 즉시 내부 장부를 변경한다. (잔액을 100에서 0으로 먼저 기록)
- 상호작용 (Interactions): 모든 내부 정리가 끝난 후에야 외부 세계와 소통한다. (이 단계에서 보너스 포인트를 묻거나, 실제 자금을 전송한다.)
만약 이 순서를 지켰다면, '공격자 C'가 transfer로 교차 재진입을 시도했을 때, '금고 A'의 장부(Storage)는 이미 0으로 변경된 상태였을 것이다. 두 번째 요청은 잔액 부족으로 거부되었을 것이다.
자동화된 시스템의 견고함은 암호화의 강도나 코드의 복잡성이 아니라, 이처럼 단순하고 엄격한 순서의 원칙을 지키는 데서 나온다. 어떤 코드도, 심지어 당신이 직접 작성한 코드의 일부라 할지라도, 무조건 신뢰해서는 안 된다.
3줄 요약
- 교차 기능 재진입은 하나의 컨트랙트 내에서, 상태를 공유하는 여러 기능(
withdraw,transfer) 간의 신뢰를 이용하는 공격이다. - 예를 들어 공격자는
withdraw기능이 내부 상태(잔고)를 변경하기 전에 외부 호출로 인해 일시 정지된 틈을 이용하여 잠금이 없는transfer기능을 호출할 수 있다. - 이로 인해 상태 불일치(Stale Data)가 발생하여 자금이 이중으로 인출되며, 이는 'Checks-Effects-Interactions' 원칙을 준수해야만 막을 수 있다.
'MACHINE: EXPLOIT' 카테고리의 다른 글
| 투명한 상자 속의 기괴한 기계: 위어드 머신(Weird Machine)의 세계 (0) | 2025.11.21 |
|---|---|
| 도둑은 창문을 깨지 않는다, 초인종을 누를 뿐 (0) | 2025.11.21 |
| 우아함의 귀환: 더하기가 다시 더하기가 되기까지 (0) | 2025.11.21 |
| 1,000개의 오답 노트: CWE가 말해주는 것들 (0) | 2025.11.20 |
| 검증과 실행 사이 그 아찔한 간극 TOCTOU에 대하여 (0) | 2025.11.15 |
| 레고인가, 조각상인가? 블록체인을 짓는 두 가지 철학 (0) | 2025.11.12 |
| 블록체인의 인터넷은 누가 움직이는가 (0) | 2025.11.12 |
| 이름은 운명이다? 메타마스크는 왜 스냅을 선택했나 (0) | 2025.11.10 |
| 지분 증명 시대의 보이지 않는 권력자들, 검증인 (0) | 2025.11.10 |
| 실행자의 유령. 코드상의 나는 진짜 나인가? (0) | 2025.11.08 |