Solidity Vulnerability Survey
Solidity vulnerability survey is a tour of common contract flaws and their fixes. Each flaw is shown as a vulnerable version that fails a property and a fixed version that holds it.
An unchecked call marks a failed payment as paid
VulnerablePay ignores the success value of its call, so a rejecting recipient is still marked paid. FixedPay requires success before marking paid.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract Rejector {
}
contract GoodReceiver {
receive() external payable {}
}
contract VulnerablePay {
mapping(address => bool) public paid;
function pay(address payable to, uint256 amount) public {
to.call{value: amount}("");
paid[to] = true;
}
}
contract FixedPay {
mapping(address => bool) public paid;
function pay(address payable to, uint256 amount) public {
(bool success, ) = to.call{value: amount}("");
require(success, "send failed");
paid[to] = true;
}
}
Division rounding strands a remainder
VulnerableSplitter pays three equal shares of ten and strands one unit. FixedSplitter gives the remainder to the last recipient.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract VulnerableSplitter {
mapping(address => uint256) public paid;
function split(uint256 total, address a, address b, address c) public {
uint256 share = total / 3;
paid[a] = share;
paid[b] = share;
paid[c] = share;
}
}
contract FixedSplitter {
mapping(address => uint256) public paid;
function split(uint256 total, address a, address b, address c) public {
uint256 share = total / 3;
paid[a] = share;
paid[b] = share;
paid[c] = total - share - share;
}
}
A missing zero check can brick the owner role
VulnerableOwner accepts the zero address as owner, and then no one can act as owner. FixedOwner rejects the zero address.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract VulnerableOwner {
address public owner;
uint256 public value;
constructor() {
owner = msg.sender;
}
function setOwner(address newOwner) public {
require(msg.sender == owner, "not owner");
owner = newOwner;
}
function setValue(uint256 newValue) public {
require(msg.sender == owner, "not owner");
value = newValue;
}
}
contract FixedOwner {
address public owner;
uint256 public value;
constructor() {
owner = msg.sender;
}
function setOwner(address newOwner) public {
require(msg.sender == owner, "not owner");
require(newOwner != address(0), "zero address");
owner = newOwner;
}
function setValue(uint256 newValue) public {
require(msg.sender == owner, "not owner");
value = newValue;
}
}
A spot price moves under a big trade
VulnerablePool reads its price from current reserves, so a big trade moves the price for the next trader. FixedPool caps one trade at a tenth of the reserve.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract VulnerablePool {
uint256 public reserveX;
uint256 public reserveY;
constructor() {
reserveX = 1000;
reserveY = 1000;
}
function price() public view returns (uint256) {
return (reserveY * 1e18) / reserveX;
}
function swap(uint256 dx) public returns (uint256) {
uint256 dy = (reserveY * dx) / (reserveX + dx);
reserveX += dx;
reserveY -= dy;
return dy;
}
}
contract FixedPool {
uint256 public reserveX;
uint256 public reserveY;
constructor() {
reserveX = 1000;
reserveY = 1000;
}
function price() public view returns (uint256) {
return (reserveY * 1e18) / reserveX;
}
function swap(uint256 dx) public returns (uint256) {
require(dx * 10 <= reserveX, "trade too big");
uint256 dy = (reserveY * dx) / (reserveX + dx);
reserveX += dx;
reserveY -= dy;
return dy;
}
}
Example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract AuditedPayout {
address public owner;
mapping(address => uint256) public shares;
constructor() {
owner = msg.sender;
}
function distribute(uint256 total, address a, address b, address c) public {
require(msg.sender == owner, "not owner");
require(a != address(0), "zero address");
require(b != address(0), "zero address");
require(c != address(0), "zero address");
uint256 share = total / 3;
shares[a] = share;
shares[b] = share;
shares[c] = total - share - share;
}
function claim() public {
uint256 amount = shares[msg.sender];
require(amount > 0, "nothing to claim");
shares[msg.sender] = 0;
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success, "send failed");
}
}
Key points
- Unchecked call: An unchecked call is a low-level call whose success value the code ignores.
- Rounding: Rounding is the loss of a remainder when division drops the fraction.
- Zero address: The zero address is the empty address that no one controls.
- Spot price: A spot price is the price read from current reserves at one moment.
- Restatement: Every flaw in this survey is fixed by checking a value the vulnerable version ignored.
In this example, AuditedPayout combines three survey fixes in one payout contract. distribute is owner only, rejects any zero address, and gives the division remainder to the last recipient. claim zeroes the share before sending and requires the send to succeed, so a failed payment never counts as paid.
Note
A survey fix is a starting point, and a full audit still checks every entry point and calculation.
Try it Yourself
Edit the code, then press Run.
Running is simulated · Remix IDE
Exercise
A splitter divides 10 units among three people with plain division. Each person is paid total divided by 3. How many units are paid in total by the vulnerable version?