Smart Contract Security

Pentesting Strategy

When hunting for vulnerabilities in the real world (on mainnet), we don’t want to spend real money when messing around with smart contract calls. Forking is a helpful technique that allows us to pretend we’re on testing mainnet like a sandbox.

Networks

Ethereum contains different networks that you can connect to. There are 4 networks that we care about:

  1. Mainnet
    • The real ethereum network that is online and is used for financial exchange and production use.
  2. Testnet
    • A decentralized blockchain that is online and is used for testing.
    • Testnet examples:
      • Rinkeby
      • Ropstein
  3. Fork
    • A copy of the mainnet/testnet being proxied into our local testing environment. We can unlock and control any wallets as if we had the private key.
    • This is useful if we want to interact with a smart contract in its current state, instead of recreating that state manually via local deployment.
  4. Local
    • Our local debugging environment. There’s a useful tool named ganache that can provide us a local blockchain.
    • To test locally:
      • Copy the solidity code to the local project
      • Deploy the smart contract to ganache
      • Recreate the state of the VM (sometimes)
        • Ex) A CTF challenge, the EVM is initialized and your account is given 20 tokens that you must use to hack the smart contract somehow. Instead of forking we can deploy locally, then deposit 20 tokens into our account manually.
Smart Contract Attack Surfaces & Tips
  1. Data within smart contracts (global vars) are not private. The EVM bytecode is published on the blockchain and there are simple ways to obtain private variables.
    • There’s a way to read any variable within the contract based on its slot index
  2. When money is sent to a smart contact without specifying a function name, this triggers the fallback function which is represented as fallback() or receive().
    • Vulnerable contracts may send money to your contract address in this way.
      • If this occurs and conditions are not checked before, this can be exploited.
        • Ex) A vulnerable contract with a withdraw() function may send the user’s funds BEFORE decrementing their balance
        • This is what’s known as a Re-entrancy attack (recursion)
    • When transfering funds, you must remember:
      • The recipient isn’t always an externally owned account (a wallet). It may be a smart contract, which will run code when the transfer is confirmed.
      • If an attacker contract’s fallback function is triggered, it may break things by reverting
  3. Modifiers are really important. They determine what functions you can leverage.
    • Ex) function isLastFloor(uint) external returns(bool);
      • This interface can be used for both reading and modifying data within the contract.
      • The view function modifier on interfaces can prevent state changes from occurring
  4. Composition can be used for working within the same block as the victim contract
    • Deploy an attack contact and pass the victim contract address as the constructor parameter so that calls can be made from the attack contract to the victim contract.
    • If “randomness” is computed based on block-related attributes, these results can be recreated within the attack contract. The only secure way to provide pseudo-randomness is using oracles.
  5. There is a difference between tx.origin and msg.sender that allows phishing attacks.
    • By convincing a victim to send funds to an attacker contract, the fallback function can be triggered - if a victim contract (e.g. victim’s bank) is then called, the tx.origin will be the victim and msg.sender will be the attacker.
    • tx.origin should never be used to authenticate identity
    • tx.origin should never be used to determine whose tokens to transfer
  6. Solidity has underflows and underflows.
    • Safe math libraries are built-in after 0.6.0 (TODO: double check this)
    • Earlier solidity versions must import SafeMath
  7. delegatecall is a way to call arbitrary functions in a contract or library.
    • This should never be in a fallback function!
    • It allows the attacker to call whatever function they want in the contract specified.
      1
      2
      3
      4
      5
      6
      fallback() external {
      (bool result,) = address(delegate).delegatecall(msg.data);
      if (result) {
      this;
      }
      }
  8. Even if a contract doesn’t mark their fallback as payable, there’s still a special way to send it money.
    • selfdestruct(_address); can be used by an attack contract to force a payment.
      • In this case the attacker function would be function attack(address _address) payable public
    • It’s important not to count on the invariant address(this).balance == 0 for any contract logic
  9. When doing type conversions (casting) it’s usually easier do it within an attacker contract.
  10. Bitwise operations are painful but elegant, and will help with smart contract hacking.
    • A ^ B = C then A ^ C = B
  11. extcodesize(caller()) is the .text section (opcodes, code instructions) size of the caller - You can hide the size of your attack contract by doing everything in the constructor
  12. For ERC-20 tokens there’s two ways to transfer funds:
    1. function transfer(address _to, uint256 _value) public
    2. function transferFrom(address _from, address _to, uint256 _value) public
      1. The approve(address _toBeApproved, _value) public function must be called first before transferFrom(toBeApproved, 100000000) will work

Invariants & Fuzzing

When I first started to learn blockchain and smart contract security in 2021 there wasn’t much learning curriculum freely available online. There were some CTFs like Ethernaut with helpful writeups, but they were mostly in the same test suite (hardhat) and lacked the general context of how this type of bug hunting theory applied to live protocols. Also, JavaScript is lame.

Jumping now to December of 2023, there’s so many freely available resources that you can even just watch videos. I don’t always have the discipline to keep digging into really weird complicated stuff like this but I’ve been watching videos and have been blown away by the quality of training resources provided by Patrick Collins and Owen Thurm.

My top blockchain security learning resource for 2024:

I got to the TSwap section of the Cyfrin course and Patrick mentioned that he plans to redo the stateful fuzzing section because he thought he explained it poorly. I disagree, but I wanted to do a deep dive myself and maybe this can help others.

Testing and Verification Approaches

First, know that this page from the Cyfrin course thoroughly describes these concepts much better.

When it comes to testing smart contracts, there’s basically 4 approaches that can be used to assert the validity of various states of a smart contract. From least to most confident, the scale is Stateless fuzzing -> Open Stateful fuzzing -> Handler Stateful fuzzing -> Formal Verification.

  • Stateless Fuzzing:
    • Testing approach using random inputs.
    • A basic function fuzzer
    • Helps identify input related edge cases, but it “forgets” the state of the contract with every fuzz attempt.
  • Open Stateful Fuzzing:
    • Expands on Stateless fuzzing by considering the contract’s internal state.
    • Includes a setUp function to initiate things properly
    • Test inputs are generated and executed in a stateful manner.
    • Basically it will call multiple functions to try to break the invariant we define. It’ll try crazy stuff in crazy orders though, so you might need a super computer if there’s a lot to fuzz.
  • Handler Stateful Fuzzing:
    • Expands on Stateful fuzzing but reduces what the fuzzer will do.
    • Way more setup with foundry
    • Orchestrates interactions with the contract using foundry.
      • Ex) Instead of modifying the contract we’re interacting with to include a valueBefore variable, we instead define it in our handler which avoids changing the original code.
    • Defines which functions and inputs should be fuzzed, which helps reduce the scope of what the fuzzer will try. This is done through something called selectors.
  • Formal Verification:
    • Using math to prove the correctness of a smart contract.
    • Hard to do.

      Stateless vs Stateful Fuzzing

To explain further:

  1. Stateless Fuzzing (AKA Fuzzing in Foundry natively, basic function fuzzer)
    • Tries random data
    • Easier to instrument
  2. Stateful Fuzzing (Invariant testing)
    • Tries random data in random order
    • Harder to instrument

With these pros and cons in mind, the Cyfrin course explains how the famous security researcher “Tincho” found a critical vulnerability in the Euler protocol by creating and instrumenting a stateful test suite, and used it to identify a codepath violating the invariant, or the expected results of the protocol.

Codebase Review

Let’s look at this source code example first.

1
2
3
4
src/invariant-break/

├── HandlerStatefulFuzzCatches.sol
├── StatefulFuzzCatches.sol

Looking at HandlerStatefulFuzzCatches.sol appears to be a typical ERC20 with a depositToken and withdrawToken function implemented

StatefulFuzzCatches.sol, on the other hand, is a contract that does some math and storage.
This is the codepath that we want the fuzzer to eventually hit.

The Handler

For stateful invariant testing there’s basically two pieces we’re going to use to instrument our protocols:

  1. Handler
  2. Target contract (aka the Invariant)

They’re both kinda instrumenting the code, but in different ways, and they connect together. This might seem confusing but this part is actually pretty straight forward. The part where you lose everyone isn’t really the solidity background and setup, it’s more around understanding how to apply financial math theory to these types of stateful tests.

The handler you can think of as mostly boilerplate code. It’s a way to overload functionality without modifying the src code. We’ll use both 1 and 2 (above) as templates and modify them when creating future invariant tests, but 1 will require less modification.

For our handler example, we’re instrumenting a protocol that has two pools:

  1. YieldERC20
    • This is an example of a “weird ERC20”, which you can search on solodit, which is an attack surface that we can hunt for in the future. Lots of protocols have strange issues within contracts that inherit ERC20.
  2. MockUSDC
    • People deposit USDC as collateral, they then mint and receive however many YieldERC20 tokens in exchange, and then they can use them to withdraw.

Again, this is a pretty typical ERC20 two LP protocol, so in the future if we’re writing our own handler for another ERC20 contract, this will only require a few modifications here and there.

How not to write an invariant test

For this first example I want to show how not to write these invariant tests.
In this InvariantFail.t.sol example let’s first talk about the statefulFuzz_testInvariantBreakFail lines that are commented, which is a function that, since this is a stateful test, will be fuzzed in random order in conjunction with the other testInvariantBreakHard function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// // Well this doesn't work...
// // cuz there are too many possible inputs! We need to narrow down the inputs with a handler
// // Uncomment this to run it because it'll break tests
// function statefulFuzz_testInvariantBreakFail() public {
// vm.startPrank(owner);
// handlerStatefulFuzzCatches.withdrawToken(mockUSDC);
// handlerStatefulFuzzCatches.withdrawToken(yeildERC20);
// vm.stopPrank();

// assert(mockUSDC.balanceOf(address(handlerStatefulFuzzCatches)) == 0);
// assert(yeildERC20.balanceOf(address(handlerStatefulFuzzCatches)) == 0);
// assert(mockUSDC.balanceOf(owner) == startingAmount);
// assert(yeildERC20.balanceOf(owner) == startingAmount);
// }

and the second part

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Our fuzz test won't catch it...
// All our withdraws should work
// And our fuzz test doesn't catch it... Hmm....abi
// Let's try stateful fuzzing
function testInvariantBreakHard(uint256 randomAmount) public {
vm.assume(randomAmount < startingAmount);
vm.startPrank(owner);
// Deposit some yeildERC20
yeildERC20.approve(address(handlerStatefulFuzzCatches), randomAmount);
handlerStatefulFuzzCatches.depositToken(yeildERC20, randomAmount);
// Withdraw some yeildERC20
handlerStatefulFuzzCatches.withdrawToken(yeildERC20);
// Deposit some mockUSDC
mockUSDC.approve(address(handlerStatefulFuzzCatches), randomAmount);
handlerStatefulFuzzCatches.depositToken(mockUSDC, randomAmount);
// Withdraw some mockUSDC
handlerStatefulFuzzCatches.withdrawToken(mockUSDC);
vm.stopPrank();

assert(mockUSDC.balanceOf(address(handlerStatefulFuzzCatches)) == 0);
assert(yeildERC20.balanceOf(address(handlerStatefulFuzzCatches)) == 0);
assert(mockUSDC.balanceOf(owner) == startingAmount);
assert(yeildERC20.balanceOf(owner) == startingAmount);
}

By the time this test runs we’ve already done the basic contract setup with foundry cheat codes.
These functions are following the right general idea, but there’s way too many possible inputs and codepaths that the fuzzer could instrument. This could maybe work with a supercomputer, but we don’t have that, so we’re going to use Foundry’s targetSelector and FuzzSelector to instrument our testing with more granularity. We want randomness, but only regarding parameters and functions that can be defined using these selectors.

Invariant Testing the Correct Way

There’s these things called selectors. It’s just an arbitrary solidity design pattern that we use as a template/example in the future. This, with a proper setup, is a better way to instrument the contract, like telling it “We only care about these 2 supported tokens, and these are the 3 functions we want used for fuzzing”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
function setUp() public {
vm.startPrank(owner);
// Give our owner 1M tokens each
yeildERC20 = new YeildERC20();
startingAmount = yeildERC20.INITIAL_SUPPLY();
mockUSDC = new MockUSDC();
mockUSDC.mint(owner, startingAmount);

supportedTokens.push(mockUSDC);
supportedTokens.push(yeildERC20);
handlerStatefulFuzzCatches = new HandlerStatefulFuzzCatches(supportedTokens);
vm.stopPrank();

handler = new Handler(handlerStatefulFuzzCatches, yeildERC20, mockUSDC);

bytes4[] memory selectors = new bytes4[](3);
selectors[0] = handler.depositYeildERC20.selector;
selectors[1] = handler.withdrawYeildERC20.selector;
selectors[2] = handler.withdrawMockUSDC.selector;

targetSelector(FuzzSelector({addr: address(handler), selectors: selectors}));
targetContract(address(handler));
}

// // THIS however, catches our bug!!!
// function statefulFuzz_testInvariantBreakHandler() public {
// vm.startPrank(owner);
// handlerStatefulFuzzCatches.withdrawToken(mockUSDC);
// handlerStatefulFuzzCatches.withdrawToken(yeildERC20);
// vm.stopPrank();

// assert(mockUSDC.balanceOf(address(handlerStatefulFuzzCatches)) == 0);
// assert(yeildERC20.balanceOf(address(handlerStatefulFuzzCatches)) == 0);
// assert(mockUSDC.balanceOf(owner) == startingAmount);
// assert(yeildERC20.balanceOf(owner) == startingAmount);
// }
}

The commented function statefulFuzz_testInvariantBreakHandler() above is the solution which breaks the invariant. I’ll explain what these functions do:

  • setUp()
    • Give our owner 1M tokens
      • Instantiate new YieldERC20 & MockUSDC contracts
      • Mint 1M tokens to the owner
    • Connect the handler and selectors
      • Instantiate a new handler, passing the supported tokens.
      • Define the selectors to be the handler’s supported tokens and then call targetContract and targetSelector to connect the components together
      • It’s worth noting that depositYeildERC20, withdrawYeildERC20, withdrawMockUSDC are all defined within test/Handler.t.sol which is some more foundry stuff instrumenting the basic deposit/withdraw functionality (which includes calls to prank, approve etc).
  • statefulFuzz_testInvariantBreakHandler()
    • Since we’ve connected the mockUSDC and yieldERC20 objects to our test suite, we can pass them around and kinda pretend that their state is indeterminate. Ultimately we want to break the invariant, which in this case can be defined as the following statements:
      • “Once the owner withdraws all their USDC and YieldERC20 tokens, their balances should be zero”
      • “Additionally, after, everything should be in its original state (the owner’s tokens should have the original starting amounts)”.
      • We then use an assert to define these invariants, and the test suite will do its magic to try to break those assertions.

T-Swap

As explained in the Cyfrin course, “T-Swap is known as an Automated Market Maker (AMM) because it doesn’t use a normal “order book” style exchange, instead it uses “Pools” of an asset. It is similar to Uniswap.”

The T-Swap protocol is explained in detail within this README.

Invariant

With T-Swap, this is a good example because the invariant can be defined with math:

  • x = Token Balance X
  • y = Token Balance Y
  • k = The constant ratio between X & Y

This looks kinda insane, and it is, but we have everything that we need (x, y, & k) to define the invariants:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
y = Token Balance Y
x = Token Balance X
x * y = k
x * y = (x + ∆x) * (y − ∆y)
∆x = Change of token balance X
∆y = Change of token balance Y
β = (∆y / y)
α = (∆x / x)

Final invariant equation without fees:
∆x = (β/(1-β)) * x
∆y = (α/(1+α)) * y

Invariant with fees
ρ = fee (between 0 & 1, aka a percentage)
γ = (1 - p) (pronounced gamma)
∆x = (β/(1-β)) * (1/γ) * x
∆y = (αγ/1+αγ) * y
  • Copyrights © 2021-2024 blindCyber

请我喝杯咖啡吧~

支付宝
微信