Wintermute Rekt
Level: https://rekt.news/wintermute-rekt-2
Here is the contract we have to break:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract UndeadHorde {
address public constant LADY_WHITEFROST
= 0x0DEaD582fa84de81e5287132d70d9a296224Cf90;
bool public isActive = true;
mapping(address => bool) public infested;
function infestDead(address _target) external {
require(isActive);
require(_fromLady(), "We only answer to our Queen Mother...");
require(_isDead(_target), "Target is still alive...");
infested[_target] = true;
}
// So we have to find a way to call this function
// The only possible way to do so is by being LADY_WHITEFROST
// So we have to find the private key associated with her address
function releaseArmy() external {
require(_fromLady(), "We only answer to our Queen Mother...");
isActive = false;
}
function _fromLady() private view returns (bool) {
return msg.sender == LADY_WHITEFROST;
}
function _isDead(address _target) private pure returns (bool) {
uint160 prefix = uint160(_target) >> 140;
return prefix == 0x0dead;
}
}
First let’s take a look to the Vanity address generator that we have: find-more-dead.js
const { BigNumber } = require("ethers");
const { arrayify, keccak256, zeroPad } = require("ethers/lib/utils");
const { Worker } = require("worker_threads");
const { CURVE } = require("@noble/secp256k1");
const MAX_UINT16 = 65535; // 2**16
const NUM_OF_THREADS = 3;
const K = "0xc01ddeadc01ddeadc01ddead";
// Generate a random private key `p`
const seed = Math.floor(Math.random() * MAX_UINT16); // 1 out of 65535 possible seeds
const privateKey = BigNumber.from(keccak256(seed)); // Determinitic private key generation
console.log(`Seed: ${seed}`);
// Start threads running `find-dead.js`
for (let i = 0; i < NUM_OF_THREADS; i++) {
// Each thread is given a private key `p + (i * K)`
const delta = BigNumber.from(K).mul(i);
// ---- thread 0: privKey + delta0
// |
// ---- thread 1: privKey + delta1
// |
// ---- thread 2: privKey + delta2
const seedKey = zeroPad(arrayify(privateKey.add(delta).mod(CURVE.n)), 32);
const thread = new Worker("./find-dead.js", {
workerData: { seedKey: seedKey },
});
thread.on("message", (msg) => {
console.log(msg);
});
}
Then in each thread we have the following script: find-dead.js
const { BigNumber } = require("ethers");
const {
getAddress: checksumAddress,
hexDataSlice,
keccak256,
} = require("ethers/lib/utils");
const { Point } = require("@noble/secp256k1");
const { parentPort, workerData } = require("worker_threads");
// Calculate new point `P` from seed key `p`
const seedKey = workerData.seedKey;
let newPoint = Point.fromPrivateKey(seedKey); // seed pub key
for (let i = 1; ; i++) {
// Increment new point `P` (i.e. `P <- P + G`)
newPoint = newPoint.add(Point.BASE); // part of the set of the candidate pub key
// Infer address of `P`
const newAddress = hexDataSlice(
keccak256(hexDataSlice("0x" + newPoint.toHex(), 1)),
12,
);
// If address is vanity address...
if (newAddress.startsWith("0x0dead")) {
// Infer the private key (i.e. `p + i`)
const deadKey = BigNumber.from(seedKey).add(i);
// Send back to parent process to print
parentPort.postMessage(
`\nPrivate Key: ${deadKey.toHexString()}\
\nAddress: ${checksumAddress(newAddress)}`,
);
}
}
We can notice that there are no more than MAX_UINT16 per thread (3 threads) private key possible, which is a reasonable amount number of private to generate.
Once we have all the possible private keys it’s not more difficult to generate all the public key associated (aka seed public key).
Here is a script to generate all possible public key seeds:
const fs = require("node:fs");
const { BigNumber } = require("ethers");
const { arrayify, keccak256, zeroPad } = require("ethers/lib/utils");
const { Point } = require("@noble/secp256k1");
const { CURVE } = require("@noble/secp256k1");
const MAX_UINT16 = 65535;
const K = "0xc01ddeadc01ddeadc01ddead";
const d0 = BigNumber.from(K).mul(0);
const d1 = BigNumber.from(K).mul(1);
const d2 = BigNumber.from(K).mul(2);
// let map = new Map();
// Generates all possible private keys (65535 * 3 = 196 605)
for (let i = 0; i < MAX_UINT16; i++) {
const privateKey = BigNumber.from(keccak256(i));
const pvKey0 = zeroPad(arrayify(privateKey.add(d0).mod(CURVE.n)), 32);
const pvKey1 = zeroPad(arrayify(privateKey.add(d1).mod(CURVE.n)), 32);
const pvKey2 = zeroPad(arrayify(privateKey.add(d2).mod(CURVE.n)), 32);
// const content = `${s0}\n${s1}\n${s2}\n`;
const seedPbKey0 = Point.fromPrivateKey(new Uint8Array(pvKey0));
const seedPbKey1 = Point.fromPrivateKey(new Uint8Array(pvKey1));
const seedPbKey2 = Point.fromPrivateKey(new Uint8Array(pvKey2));
const content = `"${seedPbKey0.toHex()}": [${pvKey0}],\n"${seedPbKey1.toHex()}": [${pvKey1}],\n"${seedPbKey2.toHex()}": [${pvKey2}],\n`;
fs.appendFileSync("./out.log", content, () => {});
}
Once the keys are generated we can put then into a file. My idea was to generate a mapping such that : pubKey ⇒ privKey
So that if we manage to find the seed public key of the vanity address of LADY_WHITEFROST it’s very easy to get the associated private key and so we break the level !
Once the script has finished to run we end up with a ~50Mb file that contains all the mapped keys, then we can convert the file to a js object so it will be easier to load it into memory: map.js
const data = {
"04b793ec11629accadfd51835c82654391fad3f7489af36440155403e366dc677808fa587ed7576c1274e4fcdf886789b72b52de5e1eed3907500d9d4d3f8aa1fb":
[
188, 54, 120, 158, 122, 30, 40, 20, 54, 70, 66, 41, 130, 143, 129, 125,
102, 18, 247, 180, 119, 214, 101, 145, 255, 150, 169, 224, 100, 188, 201,
138,
],
"046d7e35af8d3626c3b9a4846f7a3eb7bb4da9b33dc29dbd1c0254f9ea4e25b7d64753c9655cbcd5974904b0dd51a0b5fb11a7dbd676031706472c8d794800abe0":
[
188, 54, 120, 158, 122, 30, 40, 20, 54, 70, 66, 41, 130, 143, 129, 125,
102, 18, 247, 181, 55, 244, 68, 63, 191, 180, 136, 142, 36, 218, 168, 55,
],
// ...
// ...
// ...
};
module.exports = { data };
Now that we are done with this part we can go back to the address:
LADY_WHITEFROST = 0x0DEaD582fa84de81e5287132d70d9a296224Cf90;
We have to recover a public key from the address. The process implies that we use a transaction made by the said address. We can do that looking on Etherscan and I chose the following one:
0xf81d53b9d2fa44bb0c31913b55eeabf38492d187fc2fe162a6359850c2320b97
which has the followin associated encoded data (click on Get Raw Tx Hex
on the etherscan more button of the transaction):
From here we have to retrieve the r,s,v value, we can use ABDK TollKit to do that from web interface:
After, that I used the following script to recover the public from the transaction details: ransactionToPublicKey.js
const { ethers } = require("ethers");
async function recover(tx) {
const expandedSig = {
r: tx.r,
s: tx.s,
v: tx.v,
};
const signature = ethers.utils.joinSignature(expandedSig);
const txData = {
gasLimit: tx.gasLimit,
value: tx.value,
nonce: tx.nonce,
data: tx.data,
chainId: tx.chainId,
to: tx.to, // you might need to include this if it's a regular tx and not simply a contract deployment
type: tx.type,
maxFeePerGas: tx.maxFeePerGas,
maxPriorityFeePerGas: tx.maxPriorityFeePerGas,
};
const rsTx = await ethers.utils.resolveProperties(txData);
const raw = ethers.utils.serializeTransaction(rsTx); // returns RLP encoded tx
const msgHash = ethers.utils.keccak256(raw); // as specified by ECDSA
const msgBytes = ethers.utils.arrayify(msgHash); // create binary hash
return {
publicKey: ethers.utils.recoverPublicKey(msgBytes, signature),
address: ethers.utils.recoverAddress(msgBytes, signature),
};
}
recover({
r: "0x14ad83bdfc9bb697562faf6fd876b3fcda0e08bfc91589ad7e140d6e6f7f2138",
s: "0x36c1ad13b175a65fc23749d201fa382b5169e4f0ca0bbc041140077e19e4c803",
v: "0xf81d53b9d2fa44bb0c31913b55eeabf38492d187fc2fe162a6359850c2320b97",
gasLimit: 21000,
nonce: 0,
value: 100000000000000,
data: "",
chainId: 5,
to: "0xA73dB9CFB00F43241f35d6462124C11B72C765CF",
type: 2,
maxFeePerGas: 5870556900,
maxPriorityFeePerGas: 1500000000,
}).then((res) => console.log(res));
which gave me the following output :
Let’s go ! We have the public key of LADY_WHITEFROST, the only thing left to do is bruteforcing the public key so that we can retrieve the seed public key which will gave us the associated private key thanks to our mapping.
Here is the bruteforce attack script:
const { BigNumber } = require("ethers");
const { Point } = require("@noble/secp256k1");
const {
getAddress: checksumAddress,
hexDataSlice,
keccak256,
} = require("ethers/lib/utils");
console.log("Loading pub/priv key pairs into memory...");
const { data: map } = require("./map.js");
console.log("Data keys has been loaded");
// We start from the public key we found
let newPoint = Point.fromHex(
"04077029792b56144069fac2787ca35fad37f7f0634236ba02e307bff5a2f120e1c1484a687dc468e671eef339d1437d02d51949973ccfd29f33efe9aa4b9a6017",
);
for (let i = 1; ; i++) {
// Then we do the opposite operation made to find the Vanity address (add -> substract)
newPoint = newPoint.subtract(Point.BASE);
const hexNewPoint = newPoint.toHex();
// if the public key exist in the db then it's the public key seed used
// to generate the vanity public key!
if (map[hexNewPoint] !== undefined) {
const seedKey = new Uint8Array(map[hexNewPoint]);
console.log(seedKey);
const deadKey = BigNumber.from(seedKey).add(i);
// We print out the private key found
console.log(
`i = ${i}\nPrivate Key: ${deadKey.toHexString()}\nPublic key: ${hexNewPoint}`,
);
break;
}
}
Here we have it!
Usefull links:
- A vulnerability disclosed in Profanity, an Ethereum vanity address tool
- Private key safety
- It took the wintermute hacker 5 days to brute force an ETH Vanity Address…
- Profanity-brute-force
- The Profanity Address Hack — How are Vanity Addresses Generated?
- https://vast.ai/
- A Deep Dive of HOW Profanity Caused Wintermute to Lose $160M
- Get public key of any ethereum account
- ECDSA: Elliptic Curve Signatures
- How to get sender’s Ethereum address and public key from signed transaction
- ethers.js Recover public key from contract deployment via v,r,s values
- Can we generate public key from ethereum public address?
Some screen shot from my research during the quest:
Tx = 0x02f87105808459682f0085015de996e482520894a73db9cfb00f43241f35d6462124c11b72c765cf865af3107a400080c001a014ad83bdfc9bb697562faf6fd876b3fcda0e08bfc91589ad7e140d6e6f7f2138a036c1ad13b175a65fc23749d201fa382b5169e4f0ca0bbc041140077e19e4c803
From = 0x0DEaD582fa84de81e5287132d70d9a296224Cf90
To = 0xA73dB9CFB00F43241f35d6462124C11B72C765CF
r = 0x14ad83bdfc9bb697562faf6fd876b3fcda0e08bfc91589ad7e140d6e6f7f2138
s = 0x36c1ad13b175a65fc23749d201fa382b5169e4f0ca0bbc041140077e19e4c803
v = 0xf81d53b9d2fa44bb0c31913b55eeabf38492d187fc2fe162a6359850c2320b97