Anatomy of Uniswap Front-running Bot

Audit, 블록체인 보안에 관한 모든 것
Audit, 블록체인 보안에 관한 모든 것

수호의 Security Researcher 제스퍼입니다. 일전에 모종의 이유로 UNISWAP TRANSACTION을 대상으로 FRONT-RUNNING 공격을 하는 봇에 대한 분석을 하게 되었습니다. 별 대단한 내용은 없습니다만, 글로 한번 정리해 보았습니다.


Front-running이 뭐야?

Front-running이란 주식이나 다른 금융 자산을 브로커를 통해서 매매 할 때, 매매 정보를 일괄 처리하게 될 브로커나 아직 처리되지 않은 매매 정보를 알고 있는 거래소 내부인이 먼저 유리한 방향으로 거래를 해서 이익을 보는 행위를 말합니다.

탈중앙화 거래소(DEX)에는 중개인이 없잖아?

하지만 Uniswap과 같은 탈중앙화 거래소에는 중개인이 없고 스마트 컨트랙트가 그 역할을 대신하기 때문에, 중개인이 먼저 거래를 하는 것은 불가능합니다. 여기서 우리는 Ethereum 트랜잭션이 바로 처리되지 않는다는 사실에 주목해야 합니다. 우리가 Uniswap에 대한 거래 트랜잭션을 만들면, 그 트랜잭션은 대기 풀에 들어가 블록에 포함될 때까지 대기하게 됩니다.

Uniswap Front-running Bot은 그 타이밍을 노립니다. 처리되지 않은 트랜잭션들을 감시하고 있다가, Uniswap을 통한 매매 트랜잭션을 확인하면, 해당 트랜잭션보다 먼저 자신들의 공격 트랜잭션을 처리되도록 해서 유리한 매매를 한 다음, 공격 대상 트랜잭션이 처리된 이후에, 매매분을 청산해서 이익금을 챙기는 방식인 것입니다.

위의 설명으로는 이해가 잘  안 될 수 있으니, 실제 예시를 하나 들어 공격이 어떤 방식으로 이루어지는지를 보여주고, 자세한 코드 분석으로 넘어가겠습니다.

우리의 피해자 0xd7680084e582ac04fb0243a489d2f709dbaeef06 (약칭 0xd768)은 Uniswap을 통해서 10 WETH와 동일한 가치를 지닌 FalconSwap Token (약칭 FSW)를 구매하고자 합니다. 따라서 구매 트랜잭션을 대기 풀에 올려놓습니다.

계속 대기 풀을 지켜보고 있던 Front-running bot은 위의 구매 트랜잭션보다 더 빨리 블록에 편입되도록 더 비싼 가스비를 지불하는 30 WETH 어치의 FSW를 구매하는 공격 트랜잭션을  대기 풀에 올려놓습니다. 이때, 그냥 올리면 그사이에 공격 대상 트랜잭션이 먼저 처리되거나 하는 등의 위험 상황을 없애기 위해 스마트 컨트랙트를 이용해 공격 트랜잭션을 만듭니다.

그와 동시에, Front-running bot은 공격 대상 트랜잭션과 같은 가스비를 지불하는 청산 트랜잭션을 만드는데, 이때 공격 대상 트랜잭션과 같은 가스비를 지불하는 이유는 공격 대상 트랜잭션이 처리되고 나서 청산 트랜잭션이 실행되도록 해야 이익을 얻을 수 있기 때문입니다. 만약, 공격 대상 트랜잭션보다 먼저 실행된다면, 구매했다가 다시 되파는 것으로 수수료만 지불한 꼴이 되기 때문입니다. 청산 트랜잭션은 구매한 모든 FSW를 다시 WETH로 바꾸는 것을 통해 이익을 실현합니다.

아, Front-running bot을 만드는 전략에는 다양한 방법이 있습니다. 제가 이번에 분석한 Front-running bot은 Mempool Hunter 전략을 구사하는 봇 중의 하나로, 꽤 오랫동안 유효하게 공격을 해왔으며 지금도 공격을 하고 있는 봇입니다.

코드를 자세히 살펴보자!

대략적으로 Uniswap Front-running Bot이 어떻게 작동하는 지는 알았으니 본격적으로 코드를 살펴보며 어떻게 구현이 되어 있는지 알아봅시다. Front-running bot은  대기 풀을 지켜보고 스마트 컨트랙트에 공격 명령을 내리는 서버 사이드의 프로그램과, 공격을 수행하는 스마트 컨트랙트로 이루어집니다. 서버 사이드의 프로그램이 어떻게 작성되어 있는지는 알 길이 없으니, Ethereum 네트워크상에 공개되어 있는 스마트 컨트랙트의 코드를 살펴보도록 합시다.

착하게 컨트랙트 코드를 Etherscan에 올려주었으면 좋았겠지만, 그럴 일은 없습니다. 따라서, 바이트 코드를 읽어 들여야 하는데, 그것은 사람에게는 꽤나 가혹한 일입니다. 고맙게도, 바이트 코드를 어느 정도 사람이 읽을만한 코드로 바꾸어주는 Online Solidity Decompiler가 있습니다. 여기서 디컴파일한 코드와 예시로 든 트랜잭션을 기반으로 스마트 컨트랙트 코드가 어떻게 작성되어 있는지 살펴보도록 하겠습니다.

편의상 공격 대상 트랜잭션보다 먼저 수행되는 트랜잭션을 Opening 트랜잭션, 공격 이후 수행되는 청산 트랜잭션을 Closing 트랜잭션이라고 부르겠습니다.

Opening 트랜잭션

우선 Opening 트랜잭션에 실린 데이터는 다음과 같습니다.

0x0000000d000001b57100010000b973bf19253770ae139f7eda5f22c3538066eeac5db2100000000000000035291039930971a3b80000000000000001a055690d9db8000000000000000000310c19b9fe491977c000000000000000008ac7230489e80000

컨트랙트 호출 트랜잭션에서 데이터 앞의 8자리는 Method Signature입니다. 0x0000000d의 Method Signature를 가지는 Method는 func_01EC입니다. func_01EC가 어떤 일을 하는 Method인지를 살펴보면 나머지 값들이 어떻게 사용되는지 알 수 있을 것입니다.

일단 func_01EC로 가보니, msg.sender가 0xdd07249e403979bd79848c27aa5454c7e66bdee7이나 0xe73c1e4d7992a4a4f19f31531ae7b5dc352b74b0인지 확인하는 절차가 있습니다. 저 위의 두 주소가 우리가 분석하는 컨트랙트의 주인임을 알 수 있습니다.

if ((arg0 >> 0xdc) & 0xffff >= block.number % 0x2710) { goto label_0C8C; }

위의 코드로부터, 메소드 시그니처 이후의 0x00001b57값이 공격 대상 트랜잭션이 들어가게 될 블록 넘버 끝 네 자리임을 알 수 있습니다. 만약 공격 트랜잭션이 공격 대상과 다른 블록에 들어가게 되면 모든 것이 소용 없기 때문에 포함된 코드입니다.

var var2 = (arg0 >> 0x08) ~ 0x5d6cce56246559ec8e60081c80e1300870d4ea42;
var var3 = 0x00;
var var4 = var3;
var var5 = var2 & 0xffffffffffffffffffffffffffffffffffffffff;

그다음 부분에서 데이터의 일부를 가지고 xor 연산을 하는데, 이는 Uniswap의 페어 주소를 계산하는 부분입니다. 흥미로운 것은 그냥 Pair 주소를 제공할 수도 있었는데, 왜 저런 식의 연산을 수행하는지 이해하지 못하겠다는 점입니다.

어찌 되었건 얻어낸 Pair 주소를 향해서 Method Signature 0x0902f1acstaticcall 을 합니다. 0x0902f1acgetReserves()의 메소드 시그니쳐입니다.

그렇게 얻어낸 WETH의 Reserve는 서버가 제공한 인자값 00000000000000310c19b9fe491977c0와 비교합니다. 이것을 10진수로 표현하면 904.7623921164402인데, 이는 Reserve값에서 딱 10 WETH, 공격 대상 트랜잭션에서 구매하고자 하는 양을 더한 값입니다. 코드에서는 이 값보다 WETH Reserve가 작아야만 실행되도록 한 것입니다. 이를 통해서 다른 트랜잭션의 난입으로 손해를 보는 상황을 막는 것입니다.

공격 가능성을 충분히 확인한 이후에, func_26B5에게 구매에 사용할 WETH의 양, 현재 WETH Reserve, 현재 FSW Reserve를 인자로 주어 가격을 계산하게 합니다.

function func_26B5(var arg0, var arg1, var arg2) returns (var r0) {
    var var2 = arg0 * arg2 * 997;
    var var3 = arg0 * 997 + arg1 * 1000;
    return var2 / var3;
}

위의 함수를 보면 var2는 구매할 WETH에 수수료인 0.3%를 뺀 값과 FSW Reserve를 곱해 상수 K를 구하고, var3에서 거래 후 WETH Reserve 총량을 구해서 나누는 것을 통해 arg0만큼의 WETH로 얼마만큼의 FSW를 구매할 수 있는지 계산합니다.

그리고 같은 함수를 이용해서, 공격 이후에 공격 대상 트랜잭션으로 구매하게 될 FSW의 양도 계산합니다.

공격 대상이 구매에 사용할 WETH의 양, 공격에 사용할 WETH의 양, 그리고 각각의 구매에 따른 FSW 구매 양을 모두 알게 되었으므로, 또 func_26B5를 이용하여 공격 대상 트랜잭션이 수행된 이후 Closing 과정에서 얻게 될 수익을 계산합니다.

여기서는 공격 트랜잭션이 30 WETH로 111879.58729439399 FSW를 구매하였고, 공격 대상 트랜잭션은 10 WETH로 35698.50508403116765319 FSW를 구매하였으므로, 그 이후에 팔게 된다면,

FSW Reserve = 3458775.179288110579731962 (Original Reserve) - 111879.58729439399 (Bought by Bot) - 35698.50508403116765319 (Bought by Victim) = 3311197.0869096853
WETH Reserve = 894.762392116440168384 (Original Reserve) + 30 (Sold by Bot) + 10 (Sold by Victim) = 934.7623921164

In func_26B5,
var2 = 111879.58729439399 * 934.7623921164 * 997
var3 = 111879.58729439399 * 997 + 3311197.0869096853 * 1000
var2/var3 = 30.46303739508232

이 됩니다. 따라서 약 30.46 WETH로 0.46 WETH의 이익을 보게 되는데, 이때, 이는 투자 원금 30 WETH에 공격 비용인 0x0000b973 Wei를 합친 것보다 크기 때문에 공격을 하게 됩니다. 만약, 가스비도 못 건진다면, 공격하지 않는 것이지요.

공격이 이익이 될지에 대한 계산이 끝나면, func_26DE에서 공격을 수행합니다. 이 함수가 하는 일을 간단합니다. WETH를 Uniswap으로 보내고, Swap을 진행합니다. 다만 여기서 옵션을 주기에 따라서, 다른 주소로 Swap의 결과물을 보내는 분기를 만들어 두었는데, 어디에 사용하는지는 잘 모르겠습니다.

Opening 트랜잭션에서 재미있게 볼 만한 부분은 공격을 진행하면서, 공격 이득이 낮거나, 가스비 상승으로 공격이 실패했을 때, 손해를 최대한 줄이기 위해서 func_0CB2를 호출하는 부분입니다. func_0CB2에서는 미리 CREATE2로 만든 컨트랙트를 호출하는데, 호출된 컨트랙트를 보면 SELFDESTRUCT를 호출하는 것이 다입니다. 이는, 반복적으로 SELFDESTRUCT 컨트랙트를 호출하는 것을 통해서, 실패한 공격에 낭비한 가스 비용을 최대한 많이 회복하는 것입니다. SELFDESTRUCT가 왜 가스비를 돌려주는지에 대해서는 이쪽을 참조하시기 바랍니다.

Closing 트랜잭션

Closing 트랜잭션의 경우에는 메소드 시그니쳐 0xbea175cb로 func_0796에서 처리되는데, Opening처럼 조심스럽게 무언가를 계산하기보다는, 이미 공격 대상 트랜잭션이 처리되어 WETH를 지불하였는지 정도를 확인한 후 바로 func_29EE에서 Swap을 진행하며, 이 과정 중에 실패를 하거나 가스비가 초과하는 경우, 가스비를 최대한 줄이기 위해 Opening 트랜잭션에서와 같이 func_0CB2를 이용하여 SELFDESTRUCT를 호출합니다.

정리하며

지금까지 Uniswap Front-running Bot 중 Mempool Hunter 전략을 사용하는 봇의 스마트 컨트랙트를 일부 분석해 보았습니다. 관리를 위한 다른 메소드들도 많고, Closing의 경우에도 여러 옵션으로 관리 옵션을 만들어두긴 했는데, 분석을 시작하게 된 이유가 봇의 모든 작동을 이해하자는 것이라기보다는 서버로부터 어떤 정보를 받아 어떻게 처리하여 공격을 결정하는가 하는 큰 로직을 이해하기 위함이라 큰 가닥에서만 분석을 했습니다. 제가 분석을 하는 흐름을 따라 그냥 글을 쓰다 보니 전반적으로 횡설수설하고, 코드 참조도 매우 부실합니다.

분석을 진행하면서 한 가지 의문점이 있었는데, 공격을 진행할 시에, 일반적으로 공격 대상과 같은 액수의 금액을 투입해 이익을 보는 식으로 할 것이라고 생각했는데, 실제로 작동을 보니 서버에서 임의의 예산을 설정하여 공격합니다. 위의 경우에서는 10 WETH 어치를 사는 트랜잭션을 공격하기 위해 30 WETH를 사용했지요. 공격 예산을 어떻게 계산하는지가 의문입니다.

꽤 오랜 시간 동안 공격을 해왔던 봇이라 복잡할 것이라고 생각했는데, 생각했던 것보다 훨씬 단순한 구조라 놀랐습니다. 나중에 시간이 된다면, 직접 간단한 형태의 모방 컨트랙트와 서버 프로그램을 만들어 소액으로 PoC 해보는 것도 나쁘지 않겠다는 생각을 했습니다.

Audit 시리즈 모아보기
취약점에도 주민등록번호가 있다고요?
익스플로잇 개발 (z3-solver)
Anatomy of Uniswap Front-running Bot

제스퍼 블로그 바로가기