> For the complete documentation index, see [llms.txt](https://whs3-doge5k.gitbook.io/smartcontract-security-docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://whs3-doge5k.gitbook.io/smartcontract-security-docs/ethernaut/32.-impersonator.md).

# 32. Impersonator

\#Events #ECDSA #서명 가변성

> SlockDotlt의 신제품인 ECLocker는 IoT게이트 잠금장치를 Solidity 스마트 컨트랙트와 통합한 시스템입니다. Ethereum의 ECDSA 서명을 기반으로 출입 권한을 검증하며, 유효한 서명이 잠금장치에 전송되면, 시스템은 Open 이벤트를 발생시켜 인증된 제어자의 문을 자동으로 열어줍니다. SlockDotlt는 제품 출시 전에 보안 점검을 위해 당신을 고용했습니다. 누구나 문을 열 수 있게 시스템을 취약하게 만들 수 있을까요?

### 들어가기 전에

#### ECDSA

자세한 내용은 [ECDSA](/smartcontract-security-docs/basics/undefined/ecdsa.md)을 참고하자. 아래 내용은 문제 코드에 맞게 간략화 한 내용이다.

서명 생성 과정

* m을 해시하여 H(m) == `msgHash`을 구함
* **r 추출**: 임시적으로 생성되는 타원 곡선 위의 한 점(R)의 x-좌표, $r=k∗G$
* **s** 계산: `s` 값이 올바르게 계산되어야만 서명이 유효하다고 검증될 수 있다.
* **v 결정**: '복구 ID'라고도 불리는 `v`는 서명자의 공개 키를 복구하는 데 사용되는 단일 바이트 값이다. 이더리움에서는 `v` 값이 주로 **27 또는 28**을 가진다.

참고로 이 문제에선 서명 생성을 하는 함수가 나와있지 않다.

검증 과정

* **공개 키 복구:** `v`, `r`, `s` 및 `msgHash`를 사용하여 서명자의 공개 키를 복구할 수 있다.
* **서명 검증:** 공개 키를 사용하여 서명(`r`, `s`)이 `msgHash`과 일치하는지 수학적으로 확인한다. 이 과정은 서명자가 해당 메시지에 서명하는 데 사용된 개인 키를 실제로 소유하고 있음을 증명한다.

#### 서명 가변성 (Signature Malleability)

ECDSA에서 `s`와 `n-s` (여기서 `n`은 타원 곡선의 차수(order)) 모두 유효한 서명으로 인식될 수 있다. 즉, 수학적으로 유효한 서명 `(v, r, s)`가 존재할 때, 동일한 메시지에 대해 `(v, r, n - s)` 또한 유효한 서명이 될 수 있다. 이더리움에서는 `ecrecover`가 두 경우 모두 동일한 공개 키(동일한 서명자 주소)를 복구한다.

더 중요한 점은,`s` 값에만 국한되지 않고 `v`가 바뀌어도 유효한 서명을 만들 수 있다는 것이다. `(v, r, s)`가 유효한 서명일 때, `(v', r, n - s)` 또한 유효한 서명이 될 수 있다. 앞서 말했듯 여기서 `v`는 27 또는 28을 뜻한다.

한편, `v`를 결정하는 과정에서 중요한 ECC 개념이 사용된다. 서명을 생성하는 과정에서 타원 곡선 위의 특정 점`R` 이 결정되는데, 이 점은 x-좌표가 같더라도 y-좌표의 부호(parity)가 다르면 두 가지 다른 `R` 점이 존재할 수 있다. `v` 값은 이 두 가지 가능한 `R` 점 중 **어떤 점이 서명 생성에 사용되었는지**를 나타내는 '힌트' 역할을 한다.

**즉, 서명 생성 시, 특정 `R` 점의 y-좌표가 양수인 형태가 사용되었다면 `v` 의 값은`27`이 된다.**

**이때 두 `R` 점 중 어느 부호의 `R` 을 선택할지는 `s` 를 통해 결정된다. 즉, 한 점이 `s`를 사용한다면, y좌표 부호가 다른 대칭점은 `n-s` 를 사용한다.**

따라서 `s`가 `n - s` 로 변경되면, 해당 서명을 생성한 `R` 점의 **y-좌표의 부호가 바뀌게 된다. 이때,** **y-좌표의 부호가 바뀌었으므로, v 또한 그대로 27을 사용해선 안되고 28로 변경해야 한다.**

정리하면, 아래 세 특징을 이용해 유효한 서명 `(v, r, s)`에서 유효한 또 다른 서명 `(v’, r, n-s)` (이때 `v ≠ v’` )을 도출해 낼 수 있다.

* v=27 : **`R`** 점의 y-좌표가 양수
* v=28: **`R`** 점의 y-좌표가 음수
* s ↔ n-s

#### 리플레이 공격(Replay Attack)

암호화 통신에서 발생하는 보안 취약점 중 하나로, **유효하게 전송된 데이터를 가로채서 나중에 재전송함으로써 시스템이 이를 유효한 메시지로 다시 인식하게 만드는 공격**을 의미한다.

특히 블록체인 환경에서는 트랜잭션이 불변의 기록으로 남고, 디지털 서명이 신원 확인의 핵심이기 때문에 리플레이 공격이 심각한 위협이 될 수 있다.

* **토큰 전송:** 사용자가 토큰을 전송하는 트랜잭션에 서명했을때, 공격자가 이 서명을 가로채서 다시 제출하면, 사용자의 토큰이 두 번 전송될 수 있다.
* **스마트 컨트랙트 함수 호출:** 특정 스마트 컨트랙트 함수(예: "권한 변경")를 호출하는 트랜잭션 서명을 가로채서 재전송하면, 공격자가 그 함수를 임의로 다시 실행시킬 수 있다. ECLocker의 Open 이벤트가 바로 이 경우에 해당한다.

***

### 원본 소스 코드

```solidity
// 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) { //[1]
        // 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 { //[2]
        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; //[3]
        emit ControllerChanged(newController, block.timestamp);
    }
    //[4]
    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;
    }
}
```

```
[1]: ECLocker 계약이 배포될 때 초기 설정과 서명 검증을 담당한다.잠금 장치의 고유 ID(lockId)를 설정하고, 초기 컨트롤러의 서명(_signature)에서 msgHash를 계산하며, 해당 서명으로부터 초기 컨트롤러(initialController) 주소를 복구한다.
[2]: 외부에서 (r, s, v) 서명 구성 요소를 받아 _isValidSignature 함수를 호출하여 서명의 유효성을 검증한 후, 서명이 유효하면 잠금 장치가 열렸음을 알리는 Open 이벤트를 발생시킨다. 이 함수는 _isValidSignature의 검증이 취약하면 무한정 Open 할 수 있게 한다.
[3]: _isValidSignature 함수에서 controller 주소를 가진 사람만 접근 권한을 주게 되는데, 이 함수에서 유일하게 controller 주소를 바꿀 수 있다. 따라서 목표를 위해선 이 함수를 호출해야한다.
[4]: 서명자의 주소를 이더리움에서 제공하는 ecrecover 함수로 복구하여, 복구된 주소가 현재 controller와 일치하는지 확인한다. 이때, 이더리움은 기본적으로 secp256k1이라는 특정 타원 곡선만을 사용하여 ECDSA 서명을 처리하도록 설계되어 있다. 이전에 사용된 서명의 (r, s, v) 해시를 usedSignatures 매핑을 통해 기록하고, 새 서명이 들어올 때마다 이 목록을 확인해 리플레이 공격을 막고자 하였다. 그러나 서명 가변성에는 여전히 취약하다.
```

***

### 공격 시나리오

우리의 목표는 누구나 Open 이벤트를 할 수 있게 하는 것이다. 이는, controller 주소가 제한되어있지 않다는 뜻이다. 따라서 우리는 `changeController` 함수를 호출해 contoller 주소를 모두가 접근할 수 있는, `address(0)` 을 controller 주소로 설정해야 한다.

그러기 위해선 현재 유효한 서명 (r, s, v) 쌍을 파악한 뒤, 또 다른 유효한 검증을 만들어 `_isValidSignature` 를 우회해야한다.

#### Events 로그 분석

먼저 현재 유효한 서명을 파악하기 위해 Etherscan에 인스턴스 주소를 검색한 결과 아래와 같이 Events 로그를 볼 수 있었다.

<figure><img src="https://3512650551-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FvLae9iTcYKyLdHhMtRVd%2Fuploads%2FuSowUfSFwr1eZ0iDPlUB%2Fimage%20(28).png?alt=media&amp;token=d5168358-fcf5-443e-8edd-13a668c8e847" alt=""><figcaption></figcaption></figure>

Event 로그이기 때문에, 이 topic은 인스턴스를 생성하면 생성하게 되는 `Impersonator` 팩토리의 `NewLock` event라고 볼 수 있다. 즉, \[topic0] 이 `NewLock` 을 나타낸다고 보면 된다.

```solidity
 event NewLock(address indexed lockAddress, uint256 lockId, uint256 timestamp, bytes signature);
```

따라서 `NewLock` 함수의 매개변수와 구조를 보면, 우선 `indexed` 매개변수는 해당 이벤트 로그의 **`topics`** 부분에 저장되기 때문에, \[topic1]은 `indexed` 매개변수로 정의된 `lockAddress` , 즉 새로 배포된 `ECLocker` 컨트랙트의 주소라고 보면 된다.

`NewLock` 의`indexed` 를 제외한 인자는 3개이고, 이때 `signature`는 (r, s, v) 쌍이므로 topic 밑 `data` 부분에는 총 5개의 데이터가 들어가게 된다.

그러나 실제로 보면 총 7개의 데이터가 들어가 있는데, 첫 두 데이터는 순차적으로 `lockId`, `timestamp`라 보면 되고 그 뒤 두 `0x60` 데이터는 `signature`의 실제 데이터가 아닌 offset이라고 봐야 한다.&#x20;

이 이유는, `signature` 가 **동적 타입**(`bytes`)이기 때문에, 이더리움 ABI 인코딩의 기본 규칙에 따라

* **헤드 부분 (Head Part)**: 테일 부분에서 실제 데이터가 시작한다는 오프셋(offset)을 나타내는 32바이트 청크
* **테일 부분 (Tail Part):** 데이터(길이 + 내용)

이렇게 나뉘어 내용을 이루고 있다. 따라서 첫 `0x60` 은 헤드부분이고, 두번째 `0x60` 은 `signature` 의 길이를 뜻한다. **이후 세 데이터가 실제 r, s, v를 나타낸다.**

한편, r, s, v의 순서가 안 나타나 있어 어떻게 대응되는지 알아야 하는데, 이는 signature을 생성하는 부분 코드를 봐야 이해할 수 있다.

```solidity
 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
...(중략)
```

`mload` 부분을 보면 실제 데이터는 `0x20` 오프셋 다음부터 시작하여,

* `mload(add(_signature, 0x20))` -> 이 부분이 `r`에 할당된다.
* `mload(add(_signature, 0x40))` -> 이 부분이 `s`에 할당된다.
* `mload(add(_signature, 0x60))` -> 이 부분이 `v` 에 할당된다.

따라서 세 데이터는 순서대로 r, s, v임을 알 수 있다.

Events 로그의 `data`를 정리하면 아래와 같다.

```
0000000000000000000000000000000000000000000000000000000000000539  (lockId)
000000000000000000000000000000000000000000000000000000006876015c  (timestamp)
0000000000000000000000000000000000000000000000000000000000000060  ('signature'변수의 offset )
0000000000000000000000000000000000000000000000000000000000000060  ('signature'변수의 길이)
1932cb842d3e27f54f79f7be0289437381ba2410fdefbae36850bee9c41e3b91  (r)
78489c64a0db16c40ef986beccc8f069ad5041e5b992d76fe76bba057d9abff2  (s)
000000000000000000000000000000000000000000000000000000000000001b  (v)
```

따라서 최종적으로 유효한 `(v, r, s)` 쌍은 아래와 같다.

* v: 0x1b
* r: 0x1932cb842d3e27f54f79f7be0289437381ba2410fdefbae36850bee9c41e3b91
* s: 0x78489c64a0db16c40ef986beccc8f069ad5041e5b992d76fe76bba057d9abff2

#### ECDSA 서명의 특징

사전에 알아야 할 지식: 서명 가변성 (Signature Malleability)에서 언급했듯, 유효한 서명 `(v, r, s)`에서 유효한 또 다른 서명 `(v’, r, n-s)` (이때 `v ≠ v’` )을 도출해 낼 수 있다. 왜 `n-s`로 변경 할 때 `v`도 변경되어야 하는지는 앞선 설명을 참고하길 바란다.

이 방법을 통해 `usedSignatures` 매핑을 우회할 수 있다. `keccak256` 해시를 통해 `signatureHash` 를 생성하므로, 내용이 `s`에서 `n-s` 로 바뀌기만 해도 `signatureHash` 는 달라지기 때문이다.

한편, n-s를 계산하기 위해서 n을 알아야 하는데, 이는 `ecrecover` 함수는 secp256k1이라는 특정 타원 곡선만을 사용한다는 점을 이용하여, 검색하면 secp256k1의 n을 쉽게 알 수 있다.

<https://en.bitcoin.it/wiki/Secp256k1>  : 비트코인도 secp256k1을 사용하므로 이 링크를 참고하면 자세하게 알 수 있다.

#### 정리

1. `Impersonator`로 `ECLocker` 새로 생성하기
2. 유효한 `(v, r, s)` 서명 찾아 또 다른 유효한 서명 `(v’, r, n-s)` 계산하기
3. `ECLocker` 의 `changeController` 호출해 `(v’, r, n-s)` 값 전달 및 controller 주소 `address(0)` 으로 변경

***

### 공격 코드

{% code title="" %}

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {Script, console} from "forge-std/Script.sol"; //[1]

interface IImpersonator {
    function lockers(uint256) external view returns (address);
}

interface IECLocker {
    function open(uint8 v, bytes32  r, bytes32  s) external;
    function changeController(uint8 v, bytes32  r, bytes32  s, address newController) external;
}

contract Exploit is Script {
    bytes32 n =
        0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; //[2]

    function setUp() public {}

    function run() public {
        vm.startBroadcast();

        address instanceAddr = 0xFDC065562C7e3E16cC01660b544266a69751FD06; // 인스턴스 주소

        IImpersonator impersonator = IImpersonator(instanceAddr);
        IECLocker ECLocker_addr = IECLocker(impersonator.lockers(0));

        uint8 v = 0x1b; // 27
        bytes32  r = 0x1932cb842d3e27f54f79f7be0289437381ba2410fdefbae36850bee9c41e3b91;
        bytes32  s = 0x78489c64a0db16c40ef986beccc8f069ad5041e5b992d76fe76bba057d9abff2;

        uint8 newV = (v == 27) ? 28 : 27;
        bytes32  newS = bytes32(uint256(n) - uint256(s));

        ECLocker_addr.changeController(newV, r, newS, address(0)); //[3]

        vm.stopBroadcast();
    }
}

```

{% endcode %}

```
[1]: forge script를 사용하기 위해 스크립트용 라이브러리를 import 해 주어야 한다.
[2]: 검색을 통해 알아낸 secp256k1의 공개된 정보이다.
[3]: 계산한 새로운 유효 서명 (v', r, n-s)와 controller 변경을 위해 address(0)을 전달해 익스플로잇 한다.
```

코드 배포 뿐만 아니라 `lockers(0)` 호출, `lochangeController` 호출과 같은 함수 호출도 한번에 진행하기 위해 `forge script`를 사용하였다. 이 명령어는 다단계로 트랜잭션을 정의하고 실행하는 데 유용하게 사용할 수 있다. 더 자세한 내용은 [forge script](/smartcontract-security-docs/basics/6.-foundry/forge/forge-script.md)를 참고하자.

이 명령어로 자동화된 배포 및 트랜잭션 처리를 진행하였다.

```bash
 forge script script/Impersonator_Exploit.s.sol:Exploit   --rpc-url $RPC_URL   --private-key $PRIVATE_KEY   --broadcast
```

<figure><img src="https://3512650551-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FvLae9iTcYKyLdHhMtRVd%2Fuploads%2FgEdQF4hjpjMHd9M61Pz7%2Fimage%20(27).png?alt=media&amp;token=9e228796-a2a7-4821-965d-ae70f4cf75c9" alt=""><figcaption></figcaption></figure>

성공적으로 script가 실행되었다.

<figure><img src="https://3512650551-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FvLae9iTcYKyLdHhMtRVd%2Fuploads%2Fmpo4eUZlTUILyKgc9PjN%2Fimage%20(26).png?alt=media&amp;token=45a86442-9ce4-4d82-9179-fda47ea49a7e" alt=""><figcaption></figcaption></figure>

***

### :bangbang:더 알아보기:bangbang:

> 축하합니다! 당신은 타원곡선 서명(Elliptic Curve Signatures)의 비밀을 성공적으로 해독했습니다! [EIP-2](https://eips.ethereum.org/EIPS/eip-2)에서 설명된 바와 같이, 현재의 서명 검증 로직에서 `0 < s < secp256k1n` 범위를 허용할 경우, 서명 위변조(signature malleability) 문제가 발생할 수 있습니다.
>
> 누군가 기존 서명을 가지고 `s` 값을 `s` 에서 `secp256k1n - s`로 바꾸고, `v` 값을 27 ↔ 28로 변경해도 결과 서명은 여전히 동일한 서명자를 복구할 수 있습니다.
>
> 정확히 동작을 이해하고 있지 않다면, 안전한 구현체를 사용하는 것이 매우 중요합니다. 안전하게 ecrecover를 사용하는 방법은 [OpenZeppelin의 구현](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/448efeea6640bbbc09373f03fbc9c88e280147ba/contracts/utils/cryptography/ECDSA.sol#L128-L154) 예시를 참고해보세요.
