Company Simulator

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

Accounting mismatch: contract can receive ETH outside company_balance (forced ETH via SELFDESTRUCT)

Root + Impact

Description

  • Normal behavior:

    All ETH that the company uses for operations is expected to flow through the contract’s payable entrypoints (fund_cyfrin, pay_holding_debt, etc.) and be tracked in the company_balance state variable. Other code paths (withdrawals, payouts) rely on this internal accounting variable for correctness.

  • Specific issue:

The contract rejects direct ETH transfers in the __default__() fallback (it raises), but the EVM allows ETH to be force-sent via SELFDESTRUCT (or other low-level mechanisms) which bypasses __default__ and increases the contract's actual balance (address(this).balance) without updating company_balance. The contract uses the stored company_balance for all logic (net worth, share price, payouts, bankruptcy checks), so a mismatch between the actual ETH held and the tracked company_balance can break invariants, allow griefing, and produce surprising behavior.

// Root cause in the codebase with @> marks to highlight the relevant section
@> company_balance: public(uint256) # tracked internal accounting balance
# many flows update company_balance instead of relying on actual contract balance:
@> self.company_balance += msg.value # in fund_investor / fund_owner / pay_holding_debt
...
@view
@external
def get_balance() -> uint256:
"""
@notice Returns the current ETH balance of the company.
@dev Includes all funds available for operations.
@return Company's ETH balance in wei.
"""
@> return self.company_balance # <-- returns tracked variable, not address(this).balance

Risk

Likelihood:

  • This occurs when any external contract performs selfdestruct(target) pointing at the company contract (no permission required).

  • It can be done deliberately by an attacker (to force funds) or accidentally by a third party.

Impact:

1.Accounting mismatch: address(this).balance (real ETH) may be greater than company_balance (tracked ETH), breaking assumptions used by payout functions or share-price logic.

2.Unexpected behavior: functions that rely on company_balance (e.g., get_share_price, withdraw_shares, _is_bankrupt) may behave incorrectly relative to the real ETH stored. This can lead to denial-of-service for withdrawals or confusing audits.

3.Griefing: an attacker can force the contract to hold ETH that the contract logic does not consider available (or, conversely, can affect the economics in ways the accounting does not reflect). In some flows, this could be used to cause financial inconsistencies or to block recovery flows that check company_balance instead of real balance.

Proof of Concept

Explanation (brief)

Don’t assume all ETH in the contract must pass through controlled functions — either (A) make the contract resilient by using the actual contract balance (self.balance / address(this).balance) for safety-critical checks, or (B) accept direct deposits by implementing a payable receive() / __default__ that records any incoming ETH into company_balance and emits an event. Option (A) is safest for critical invariants; Option (B) keeps your existing bookkeeping but requires that every incoming ETH path increments company_balance.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/*
PoC: Force-send ETH into a target contract via selfdestruct, demonstrating
that the target's tracked company_balance remains unchanged while
address(this).balance increases.
Replace `TARGET` with the deployed address of the vulnerable contract.
*/
contract ForceSend {
constructor(address payable target) payable {
// On deployment, give this contract some ETH (send via value)
// Then selfdestruct to force ETH into `target` bypassing its fallback.
// Example: deploy with value 1 ether and set target to the Vyper contract address.
selfdestruct(target);
}
}
/*
How to reproduce:
1. Deploy the vulnerable Vyper contract (the one you provided). Observe:
- `get_balance()` returns 0 (company_balance tracked variable).
- address(this).balance is also 0 initially.
2. Deploy ForceSend with:
- constructor param `target` = vulnerable contract address
- send value 1 ether at deployment (so ForceSend is funded)
Example (Hardhat):
const Factory = await ethers.getContractFactory("ForceSend");
await Factory.deploy(vulnerableAddress, { value: ethers.utils.parseEther("1") });
3. After ForceSend construction completes, check balances:
- The vulnerable contract's on-chain native balance increased by 1 ether:
(await ethers.provider.getBalance(vulnerableAddress)) // shows +1 ether
- But calling vulnerableContract.get_balance() still returns 0 (company_balance unchanged).
4. This demonstrates contract has real ETH that the contract's accounting does not track.
*/

Recommended Mitigation

Explanation

make the contract resilient by using the actual contract balance (self.balance / address(this).balance) for safety-critical checks

- remove this code
+ add this code
-@view
-@external
-def get_balance() -> uint256:
- return self.company_balance
+@view
+@external
+def get_balance() -> uint256:
+ """
+ Return the actual ETH held by the contract. Use this quantity for net worth and payouts.
+ """
+ return self.balance
+
+# Example: Update other internal calculations to use self.balance instead of company_balance
+# e.g., in get_share_price() compute net_worth from self.balance - self.holding_debt
Updates

Lead Judging Commences

0xshaedyw Lead Judge
6 days ago
0xshaedyw Lead Judge 4 days ago
Submission Judgement Published
Invalidated
Reason: Known issue

Support

FAQs

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