Ethernaut 37 Impersonator Two
Ethernaut 37 Impersonator TwoEthernaut 37 Impersonator TwoEthernaut 37 Impersonator Two 문제 풀이Ethernaut 37 Impersonator Two writeup
Contents
문제
지문
The goal of this level is for you to steal all the funds from the contract. Things that might help:
- Look carefully at the 2 signatures that the owner of the contract used to lock it and set the admin.
코드
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {Ownable} from "openzeppelin-contracts-08/access/Ownable.sol";
import {ECDSA} from "openzeppelin-contracts-08/utils/cryptography/ECDSA.sol";
import {Strings} from "openzeppelin-contracts-08/utils/Strings.sol";
contract ImpersonatorTwo is Ownable {
using Strings for uint256;
error NotAdmin();
error InvalidSignature();
error FundsLocked();
address public admin;
uint256 public nonce;
bool locked;
constructor() payable {}
modifier onlyAdmin() {
require(msg.sender == admin, NotAdmin());
_;
}
function setAdmin(bytes memory signature, address newAdmin) public {
string memory message = string(abi.encodePacked("admin", nonce.toString(), newAdmin));
require(_verify(hash_message(message), signature), InvalidSignature());
nonce++;
admin = newAdmin;
}
function switchLock(bytes memory signature) public {
string memory message = string(abi.encodePacked("lock", nonce.toString()));
require(_verify(hash_message(message), signature), InvalidSignature());
nonce++;
locked = !locked;
}
function withdraw() public onlyAdmin {
require(!locked, FundsLocked());
payable(admin).transfer(address(this).balance);
}
function hash_message(string memory message) public pure returns (bytes32) {
return ECDSA.toEthSignedMessageHash(abi.encodePacked(message));
}
function _verify(bytes32 hash, bytes memory signature) internal view returns (bool) {
return ECDSA.recover(hash, signature) == owner();
}
}배경지식
ECDSA 서명은 메시지 해시를 , 개인키를 , secp256k1의 group order를 이라고 할 때 대략 다음 형태로 만들어진다.
여기서 는 서명마다 새로 써야 하는 nonce다. 서명 결과에 들어가는 은 의 x좌표에서 나온다.
서로 다른 메시지에 대한 두 서명의 이 같다면 같은 를 재사용했다는 강한 신호다.
같은 개인키 와 같은 로 두 메시지 , 를 서명했다고 하자.
두 식을 빼면 개인키 항이 사라진다.
여기서 가 나온다.
그리고 다시 첫 번째 식에 대입하면 개인키도 나온다.
즉 같은 을 가진 두 서명과 각각의 메시지 해시만 알면 owner의 개인키를 복구할 수 있다.
컨트랙트는 ECDSA.toEthSignedMessageHash를 사용한다. 따라서 서명 대상은 단순한 keccak256(message)가 아니라 다음 prefix가 붙은 해시다.
keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n", message.length, message))setAdmin의 메시지 abi.encodePacked("admin", nonce.toString(), newAdmin)에서는 newAdmin이 0x... 문자열이 아니라 20바이트 address 값 그대로 이어 붙는다.
문제 코드 분석
힌트에서는 owner가 lock과 admin 설정에 사용한 두 서명을 보라고 한다. 실제 인스턴스 생성 시에는 팩토리에서 다음 순서로 호출된다.
bytes constant SWITCH_LOCK_SIG = abi.encodePacked(
hex"e5648161e95dbf2bfc687b72b745269fa906031e2108118050aba59524a23c40", // r
hex"70026fc30e4e02a15468de57155b080f405bd5b88af05412a9c3217e028537e3", // s
uint8(27) // v
);
bytes constant SET_ADMIN_SIG = abi.encodePacked(
hex"e5648161e95dbf2bfc687b72b745269fa906031e2108118050aba59524a23c40", // r
hex"4c3ac03b268ae1d2aca1201e8a936adf578a8b95a49986d54de87cd0ccb68a79", // s
uint8(27) // v
);
address constant OWNER = 0x03E2cf81BBE61D1fD1421aFF98e8605a5A9e953a;
address constant ADMIN = 0xADa4aFfe581d1A31d7F75E1c5a3A98b2D4C40f68;
instance.transferOwnership(OWNER);
instance.switchLock(SWITCH_LOCK_SIG);
instance.setAdmin(SET_ADMIN_SIG, ADMIN);두 서명은 r이 같다.
r = e5648161e95dbf2bfc687b72b745269fa906031e2108118050aba59524a23c40서명 nonce 가 재사용됐으므로, switchLock에 쓰인 메시지와 setAdmin에 쓰인 메시지 해시를 알면 owner 개인키를 복구할 수 있다.
팩토리 호출 순서를 기준으로 처음 nonce는 0이다.
function switchLock(bytes memory signature) public {
string memory message = string(abi.encodePacked("lock", nonce.toString()));
require(_verify(hash_message(message), signature), InvalidSignature());
nonce++;
locked = !locked;
}첫 호출은 switchLock이므로 메시지는 "lock0"이다. 이 호출 뒤 nonce는 1이 되고 locked는 true가 된다.
function setAdmin(bytes memory signature, address newAdmin) public {
string memory message = string(abi.encodePacked("admin", nonce.toString(), newAdmin));
require(_verify(hash_message(message), signature), InvalidSignature());
nonce++;
admin = newAdmin;
}두 번째 호출은 setAdmin이므로 메시지는 abi.encodePacked("admin", "1", ADMIN)이다. 이 호출 뒤 nonce는 2가 되고 admin은 초기 admin 주소가 된다.
서명 검증은 owner 주소로 recover되는지만 본다.
function _verify(bytes32 hash, bytes memory signature) internal view returns (bool) {
return ECDSA.recover(hash, signature) == owner();
}서명 사용 여부를 저장하지도 않고, signer가 msg.sender인지도 확인하지 않는다. owner 개인키만 복구하면 이후 nonce에 맞는 새 owner 서명을 만들 수 있다.
출금 조건은 두 가지다.
function withdraw() public onlyAdmin {
require(!locked, FundsLocked());
payable(admin).transfer(address(this).balance);
}admin이 우리 주소여야 하고, locked가 false여야 한다. 초기 상태는 nonce = 2, locked = true, admin = ADMIN이므로 admin2<player>에 대한 서명으로 admin을 바꾸고, 이어서 lock3에 대한 서명으로 잠금을 다시 끄면 된다.
풀이
두 초기 서명에서 owner 개인키를 복구하면 된다.
switchLock초기 서명은lock0에 대한 서명이다.setAdmin초기 서명은abi.encodePacked("admin", "1", INITIAL_ADMIN)에 대한 서명이다.- 두 서명의
r이 같으므로 같은 를 재사용했다. - 위 공식으로 owner 개인키 를 복구한다.
- 현재 플레이어 주소로
admin2<player>메시지를 서명해setAdmin을 호출한다. - 그 다음 nonce는 3이므로
lock3메시지를 서명해switchLock을 호출한다. - 이제
admin == player,locked == false이므로withdraw로 잔액을 가져온다.
vm.sign은 임의 private key와 digest로 서명을 만들어주므로, 복구한 owner private key는 문제 컨트랙트가 요구하는 bytes signature를 만드는 데만 쓴다. 트랜잭션은 플레이어의 PRIVATE_KEY로 보낸다.
익스플로잇
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "forge-std/Script.sol";
interface IImpersonatorTwo {
function nonce() external view returns (uint256);
function owner() external view returns (address);
function admin() external view returns (address);
function setAdmin(bytes memory signature, address newAdmin) external;
function switchLock(bytes memory signature) external;
function withdraw() external;
}
contract Sol37 is Script {
uint256 private constant SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141;
uint256 private constant SECP256K1_HALF_N = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0;
uint256 private constant R = 0xe5648161e95dbf2bfc687b72b745269fa906031e2108118050aba59524a23c40;
uint256 private constant SWITCH_LOCK_S = 0x70026fc30e4e02a15468de57155b080f405bd5b88af05412a9c3217e028537e3;
uint256 private constant SET_ADMIN_S = 0x4c3ac03b268ae1d2aca1201e8a936adf578a8b95a49986d54de87cd0ccb68a79;
address private constant OWNER = 0x03E2cf81BBE61D1fD1421aFF98e8605a5A9e953a;
address private constant INITIAL_ADMIN = 0xADa4aFfe581d1A31d7F75E1c5a3A98b2D4C40f68;
function run() external {
uint256 privateKey = vm.envUint("PRIVATE_KEY");
address player = vm.addr(privateKey);
IImpersonatorTwo target = IImpersonatorTwo(vm.envAddress("IMPERSONATOR_TWO_INSTANCE"));
uint256 ownerPrivateKey = _recoverOwnerPrivateKey();
require(vm.addr(ownerPrivateKey) == OWNER, "owner key recovery failed");
require(target.owner() == OWNER, "unexpected owner");
uint256 nonce = target.nonce();
bytes32 setAdminHash = _hashMessage(abi.encodePacked("admin", _toString(nonce), player));
bytes memory setAdminSignature = _sign(ownerPrivateKey, setAdminHash);
bytes32 switchLockHash = _hashMessage(abi.encodePacked("lock", _toString(nonce + 1)));
bytes memory switchLockSignature = _sign(ownerPrivateKey, switchLockHash);
vm.startBroadcast(privateKey);
target.setAdmin(setAdminSignature, player);
target.switchLock(switchLockSignature);
target.withdraw();
vm.stopBroadcast();
require(target.admin() == player, "admin was not changed");
require(address(target).balance == 0, "funds still remain");
}
function _recoverOwnerPrivateKey() private view returns (uint256) {
bytes32 lockHash = _hashMessage(abi.encodePacked("lock", "0"));
bytes32 adminHash = _hashMessage(abi.encodePacked("admin", "1", INITIAL_ADMIN));
uint256 k = mulmod(
_subMod(uint256(lockHash), uint256(adminHash)),
_modInverse(_subMod(SWITCH_LOCK_S, SET_ADMIN_S)),
SECP256K1_N
);
return mulmod(_subMod(mulmod(SWITCH_LOCK_S, k, SECP256K1_N), uint256(lockHash)), _modInverse(R), SECP256K1_N);
}
function _sign(uint256 privateKey, bytes32 digest) private pure returns (bytes memory) {
(uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest);
uint256 sValue = uint256(s);
if (sValue > SECP256K1_HALF_N) {
s = bytes32(SECP256K1_N - sValue);
v = 55 - v;
}
return abi.encodePacked(r, s, v);
}
function _hashMessage(bytes memory message) private pure returns (bytes32) {
return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n", _toString(message.length), message));
}
function _modInverse(uint256 value) private view returns (uint256) {
return _modExp(value, SECP256K1_N - 2, SECP256K1_N);
}
function _modExp(uint256 base, uint256 exponent, uint256 modulus) private view returns (uint256 result) {
bytes memory input = abi.encode(uint256(32), uint256(32), uint256(32), base, exponent, modulus);
bool success;
assembly {
success := staticcall(gas(), 0x05, add(input, 0x20), mload(input), 0x00, 0x20)
result := mload(0x00)
}
require(success, "modexp failed");
}
function _subMod(uint256 left, uint256 right) private pure returns (uint256) {
return addmod(left % SECP256K1_N, SECP256K1_N - (right % SECP256K1_N), SECP256K1_N);
}
function _toString(uint256 value) private pure returns (string memory) {
if (value == 0) {
return "0";
}
uint256 temp = value;
uint256 digits;
while (temp != 0) {
digits++;
temp /= 10;
}
bytes memory buffer = new bytes(digits);
while (value != 0) {
digits--;
buffer[digits] = bytes1(uint8(48 + (value % 10)));
value /= 10;
}
return string(buffer);
}
}
Challenge
Description
The goal of this level is for you to steal all the funds from the contract. Things that might help:
- Look carefully at the 2 signatures that the owner of the contract used to lock it and set the admin.
Code
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {Ownable} from "openzeppelin-contracts-08/access/Ownable.sol";
import {ECDSA} from "openzeppelin-contracts-08/utils/cryptography/ECDSA.sol";
import {Strings} from "openzeppelin-contracts-08/utils/Strings.sol";
contract ImpersonatorTwo is Ownable {
using Strings for uint256;
error NotAdmin();
error InvalidSignature();
error FundsLocked();
address public admin;
uint256 public nonce;
bool locked;
constructor() payable {}
modifier onlyAdmin() {
require(msg.sender == admin, NotAdmin());
_;
}
function setAdmin(bytes memory signature, address newAdmin) public {
string memory message = string(abi.encodePacked("admin", nonce.toString(), newAdmin));
require(_verify(hash_message(message), signature), InvalidSignature());
nonce++;
admin = newAdmin;
}
function switchLock(bytes memory signature) public {
string memory message = string(abi.encodePacked("lock", nonce.toString()));
require(_verify(hash_message(message), signature), InvalidSignature());
nonce++;
locked = !locked;
}
function withdraw() public onlyAdmin {
require(!locked, FundsLocked());
payable(admin).transfer(address(this).balance);
}
function hash_message(string memory message) public pure returns (bytes32) {
return ECDSA.toEthSignedMessageHash(abi.encodePacked(message));
}
function _verify(bytes32 hash, bytes memory signature) internal view returns (bool) {
return ECDSA.recover(hash, signature) == owner();
}
}Background
With the message hash , the private key , and the secp256k1 group order , an ECDSA signature is produced roughly as follows.
Here is a nonce that must be freshly chosen for each signature. The that goes into the signature comes from the x-coordinate of .
If the of two signatures over different messages is the same, that is a strong signal that the same was reused.
Suppose two messages and were signed with the same private key and the same .
Subtracting the two equations cancels the private key term.
This gives .
And substituting back into the first equation yields the private key as well.
So two signatures with the same and their message hashes are enough to recover the owner's private key.
The contract uses ECDSA.toEthSignedMessageHash. So what gets signed is not a plain keccak256(message) but a hash with the following prefix.
keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n", message.length, message))In the setAdmin message, abi.encodePacked("admin", nonce.toString(), newAdmin), newAdmin is appended as a raw 20-byte address value, not a 0x... string.
Code analysis
The hint tells us to look at the two signatures the owner used to lock the contract and to set the admin. When the instance is created, the factory calls them in this order.
bytes constant SWITCH_LOCK_SIG = abi.encodePacked(
hex"e5648161e95dbf2bfc687b72b745269fa906031e2108118050aba59524a23c40", // r
hex"70026fc30e4e02a15468de57155b080f405bd5b88af05412a9c3217e028537e3", // s
uint8(27) // v
);
bytes constant SET_ADMIN_SIG = abi.encodePacked(
hex"e5648161e95dbf2bfc687b72b745269fa906031e2108118050aba59524a23c40", // r
hex"4c3ac03b268ae1d2aca1201e8a936adf578a8b95a49986d54de87cd0ccb68a79", // s
uint8(27) // v
);
address constant OWNER = 0x03E2cf81BBE61D1fD1421aFF98e8605a5A9e953a;
address constant ADMIN = 0xADa4aFfe581d1A31d7F75E1c5a3A98b2D4C40f68;
instance.transferOwnership(OWNER);
instance.switchLock(SWITCH_LOCK_SIG);
instance.setAdmin(SET_ADMIN_SIG, ADMIN);The two signatures have the same r.
r = e5648161e95dbf2bfc687b72b745269fa906031e2108118050aba59524a23c40Since the signature nonce was reused, knowing the message hashes used for switchLock and setAdmin lets us recover the owner's private key.
Based on the factory call order, nonce starts at 0.
function switchLock(bytes memory signature) public {
string memory message = string(abi.encodePacked("lock", nonce.toString()));
require(_verify(hash_message(message), signature), InvalidSignature());
nonce++;
locked = !locked;
}The first call is switchLock, so the message is "lock0". After this call nonce becomes 1 and locked becomes true.
function setAdmin(bytes memory signature, address newAdmin) public {
string memory message = string(abi.encodePacked("admin", nonce.toString(), newAdmin));
require(_verify(hash_message(message), signature), InvalidSignature());
nonce++;
admin = newAdmin;
}The second call is setAdmin, so the message is abi.encodePacked("admin", "1", ADMIN). After this call nonce becomes 2 and admin becomes the initial admin address.
Signature verification only checks whether it recovers to the owner address.
function _verify(bytes32 hash, bytes memory signature) internal view returns (bool) {
return ECDSA.recover(hash, signature) == owner();
}It does not store whether a signature has been used, nor does it check that the signer is msg.sender. With the owner's private key recovered, we can sign new owner messages for the current nonce.
There are two conditions to withdraw.
function withdraw() public onlyAdmin {
require(!locked, FundsLocked());
payable(admin).transfer(address(this).balance);
}admin must be our address, and locked must be false. The initial state is nonce = 2, locked = true, admin = ADMIN, so we change admin with a signature over admin2<player>, then turn the lock back off with a signature over lock3.
Solution
Recover the owner's private key from the two initial signatures.
- The initial
switchLocksignature is a signature overlock0. - The initial
setAdminsignature is a signature overabi.encodePacked("admin", "1", INITIAL_ADMIN). - Since the
rof the two signatures is the same, the same was reused. - Recover the owner's private key with the formula above.
- Sign the
admin2<player>message with the current player address and callsetAdmin. - The next nonce is then 3, so sign the
lock3message and callswitchLock. - Now
admin == playerandlocked == false, so withdraw the balance withwithdraw.
vm.sign produces a signature from an arbitrary private key and digest, so the recovered owner private key only produces the bytes signatures the challenge contract requires. The transactions are sent with the player's PRIVATE_KEY.
Exploit
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "forge-std/Script.sol";
interface IImpersonatorTwo {
function nonce() external view returns (uint256);
function owner() external view returns (address);
function admin() external view returns (address);
function setAdmin(bytes memory signature, address newAdmin) external;
function switchLock(bytes memory signature) external;
function withdraw() external;
}
contract Sol37 is Script {
uint256 private constant SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141;
uint256 private constant SECP256K1_HALF_N = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0;
uint256 private constant R = 0xe5648161e95dbf2bfc687b72b745269fa906031e2108118050aba59524a23c40;
uint256 private constant SWITCH_LOCK_S = 0x70026fc30e4e02a15468de57155b080f405bd5b88af05412a9c3217e028537e3;
uint256 private constant SET_ADMIN_S = 0x4c3ac03b268ae1d2aca1201e8a936adf578a8b95a49986d54de87cd0ccb68a79;
address private constant OWNER = 0x03E2cf81BBE61D1fD1421aFF98e8605a5A9e953a;
address private constant INITIAL_ADMIN = 0xADa4aFfe581d1A31d7F75E1c5a3A98b2D4C40f68;
function run() external {
uint256 privateKey = vm.envUint("PRIVATE_KEY");
address player = vm.addr(privateKey);
IImpersonatorTwo target = IImpersonatorTwo(vm.envAddress("IMPERSONATOR_TWO_INSTANCE"));
uint256 ownerPrivateKey = _recoverOwnerPrivateKey();
require(vm.addr(ownerPrivateKey) == OWNER, "owner key recovery failed");
require(target.owner() == OWNER, "unexpected owner");
uint256 nonce = target.nonce();
bytes32 setAdminHash = _hashMessage(abi.encodePacked("admin", _toString(nonce), player));
bytes memory setAdminSignature = _sign(ownerPrivateKey, setAdminHash);
bytes32 switchLockHash = _hashMessage(abi.encodePacked("lock", _toString(nonce + 1)));
bytes memory switchLockSignature = _sign(ownerPrivateKey, switchLockHash);
vm.startBroadcast(privateKey);
target.setAdmin(setAdminSignature, player);
target.switchLock(switchLockSignature);
target.withdraw();
vm.stopBroadcast();
require(target.admin() == player, "admin was not changed");
require(address(target).balance == 0, "funds still remain");
}
function _recoverOwnerPrivateKey() private view returns (uint256) {
bytes32 lockHash = _hashMessage(abi.encodePacked("lock", "0"));
bytes32 adminHash = _hashMessage(abi.encodePacked("admin", "1", INITIAL_ADMIN));
uint256 k = mulmod(
_subMod(uint256(lockHash), uint256(adminHash)),
_modInverse(_subMod(SWITCH_LOCK_S, SET_ADMIN_S)),
SECP256K1_N
);
return mulmod(_subMod(mulmod(SWITCH_LOCK_S, k, SECP256K1_N), uint256(lockHash)), _modInverse(R), SECP256K1_N);
}
function _sign(uint256 privateKey, bytes32 digest) private pure returns (bytes memory) {
(uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest);
uint256 sValue = uint256(s);
if (sValue > SECP256K1_HALF_N) {
s = bytes32(SECP256K1_N - sValue);
v = 55 - v;
}
return abi.encodePacked(r, s, v);
}
function _hashMessage(bytes memory message) private pure returns (bytes32) {
return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n", _toString(message.length), message));
}
function _modInverse(uint256 value) private view returns (uint256) {
return _modExp(value, SECP256K1_N - 2, SECP256K1_N);
}
function _modExp(uint256 base, uint256 exponent, uint256 modulus) private view returns (uint256 result) {
bytes memory input = abi.encode(uint256(32), uint256(32), uint256(32), base, exponent, modulus);
bool success;
assembly {
success := staticcall(gas(), 0x05, add(input, 0x20), mload(input), 0x00, 0x20)
result := mload(0x00)
}
require(success, "modexp failed");
}
function _subMod(uint256 left, uint256 right) private pure returns (uint256) {
return addmod(left % SECP256K1_N, SECP256K1_N - (right % SECP256K1_N), SECP256K1_N);
}
function _toString(uint256 value) private pure returns (string memory) {
if (value == 0) {
return "0";
}
uint256 temp = value;
uint256 digits;
while (temp != 0) {
digits++;
temp /= 10;
}
bytes memory buffer = new bytes(digits);
while (value != 0) {
digits--;
buffer[digits] = bytes1(uint8(48 + (value % 10)));
value /= 10;
}
return string(buffer);
}
}