add valid finding, ai finding, and retro
Some checks failed
CI / Foundry project (push) Has been cancelled
Some checks failed
CI / Foundry project (push) Has been cancelled
This commit is contained in:
parent
5726fc6b9a
commit
09ed9015e5
205
agent-finding.md
Normal file
205
agent-finding.md
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
# DatingDapp Smart Contract Audit Findings
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This audit was conducted on the DatingDapp smart contract system, which consists of three main contracts:
|
||||||
|
- SoulboundProfileNFT.sol
|
||||||
|
- LikeRegistry.sol
|
||||||
|
- MultiSig.sol
|
||||||
|
|
||||||
|
## High Severity Findings
|
||||||
|
|
||||||
|
### [H-01] Blocked Profile Can Bypass Restrictions by Reminting
|
||||||
|
**Contract**: SoulboundProfileNFT.sol => this is actually Medium
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
The `blockProfile` function in SoulboundProfileNFT contract fails to permanently prevent blocked addresses from participating in the platform.
|
||||||
|
|
||||||
|
**Impact**:
|
||||||
|
- Blocked malicious users can immediately create new profiles
|
||||||
|
- Platform security measures can be circumvented
|
||||||
|
- Admin actions become ineffective
|
||||||
|
|
||||||
|
**Proof of Concept**:
|
||||||
|
1. Admin blocks user A using `blockProfile(address A)`
|
||||||
|
2. User A's profile is burned and mappings are cleared
|
||||||
|
3. User A can immediately call `mintProfile()` to create a new profile
|
||||||
|
|
||||||
|
**Recommendation**:
|
||||||
|
Implement a blacklist mapping to track blocked addresses:
|
||||||
|
```solidity
|
||||||
|
mapping(address => bool) public blockedAddresses;
|
||||||
|
|
||||||
|
function blockProfile(address blockAddress) external onlyOwner {
|
||||||
|
blockedAddresses[blockAddress] = true;
|
||||||
|
uint256 tokenId = profileToToken[blockAddress];
|
||||||
|
require(tokenId != 0, "No profile found");
|
||||||
|
|
||||||
|
_burn(tokenId);
|
||||||
|
delete profileToToken[blockAddress];
|
||||||
|
delete _profiles[tokenId];
|
||||||
|
|
||||||
|
emit ProfileBurned(blockAddress, tokenId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mintProfile(string memory name, uint8 age, string memory profileImage) external {
|
||||||
|
require(!blockedAddresses[msg.sender], "Address is blocked");
|
||||||
|
// ... rest of the function
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### [H-02] Incorrect Balance Management in Match Rewards
|
||||||
|
**Contract**: LikeRegistry.sol
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
In the `matchRewards` function, user balances are set to zero before their values are used in calculations, resulting in zero rewards distribution.
|
||||||
|
|
||||||
|
**Impact**:
|
||||||
|
- Users lose all deposited funds when matched
|
||||||
|
- No rewards are distributed to matched pairs
|
||||||
|
- Funds remain locked in the contract
|
||||||
|
|
||||||
|
**Proof of Concept**:
|
||||||
|
```solidity
|
||||||
|
function matchRewards(address from, address to) internal returns (address) {
|
||||||
|
userBalances[from] = 0; // Balance cleared before use
|
||||||
|
userBalances[to] = 0; // Balance cleared before use
|
||||||
|
|
||||||
|
uint256 matchUserOne = userBalances[from]; // Will always be 0
|
||||||
|
uint256 matchUserTwo = userBalances[to]; // Will always be 0
|
||||||
|
// ... rest of the function
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommendation**:
|
||||||
|
Store balances in temporary variables before clearing:
|
||||||
|
```solidity
|
||||||
|
function matchRewards(address from, address to) internal returns (address) {
|
||||||
|
uint256 matchUserOne = userBalances[from];
|
||||||
|
uint256 matchUserTwo = userBalances[to];
|
||||||
|
|
||||||
|
userBalances[from] = 0;
|
||||||
|
userBalances[to] = 0;
|
||||||
|
|
||||||
|
uint256 totalRewards = matchUserOne + matchUserTwo;
|
||||||
|
// ... rest of the function
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### [H-03] ETH Lock Risk in LikeRegistry
|
||||||
|
**Contract**: LikeRegistry.sol => invalid, by design / info
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
The `receive()` function accepts ETH and updates user balances, but there's no mechanism for users to withdraw their funds directly.
|
||||||
|
|
||||||
|
**Impact**:
|
||||||
|
- User funds can be permanently locked in the contract
|
||||||
|
- No way to recover accidentally sent ETH
|
||||||
|
- Potential loss of user funds
|
||||||
|
|
||||||
|
**Recommendation**:
|
||||||
|
Add a withdrawal function for direct deposits:
|
||||||
|
```solidity
|
||||||
|
function withdrawBalance() external {
|
||||||
|
uint256 amount = userBalances[msg.sender];
|
||||||
|
require(amount > 0, "No balance to withdraw");
|
||||||
|
|
||||||
|
userBalances[msg.sender] = 0;
|
||||||
|
(bool success,) = payable(msg.sender).call{value: amount}("");
|
||||||
|
require(success, "Transfer failed");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Medium Severity Findings
|
||||||
|
|
||||||
|
### [M-01] Potential Fund Lock in MultiSig => invalid, info, by design
|
||||||
|
**Contract**: MultiSig.sol
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
The MultiSig contract lacks recovery mechanisms if one owner becomes unresponsive or loses their keys.
|
||||||
|
|
||||||
|
**Impact**:
|
||||||
|
- Funds could be permanently locked if one owner is unavailable
|
||||||
|
- No way to replace or update owners
|
||||||
|
- No emergency withdrawal mechanism
|
||||||
|
|
||||||
|
**Recommendation**:
|
||||||
|
Implement a timelock mechanism for emergency withdrawals or owner replacement functionality.
|
||||||
|
|
||||||
|
### [M-02] Missing Profile Update Functionality => invalid
|
||||||
|
**Contract**: SoulboundProfileNFT.sol
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
Users cannot update their profile information without burning and reminting their NFT.
|
||||||
|
|
||||||
|
**Impact**:
|
||||||
|
- Poor user experience
|
||||||
|
- Unnecessary gas costs for profile updates
|
||||||
|
- Historical data loss when profiles are updated
|
||||||
|
|
||||||
|
**Recommendation**:
|
||||||
|
Add a profile update function with proper access controls.
|
||||||
|
|
||||||
|
## Low Severity Findings
|
||||||
|
|
||||||
|
### [L-01] Insufficient Input Validation => invalid
|
||||||
|
**Contract**: SoulboundProfileNFT.sol
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
- No validation for name length
|
||||||
|
- No validation for profile image URL format
|
||||||
|
- No age restrictions or validation
|
||||||
|
|
||||||
|
**Recommendation**:
|
||||||
|
Add proper input validation:
|
||||||
|
```solidity
|
||||||
|
function mintProfile(string memory name, uint8 age, string memory profileImage) external {
|
||||||
|
require(bytes(name).length > 0 && bytes(name).length <= 50, "Invalid name length");
|
||||||
|
require(age >= 18 && age <= 100, "Invalid age");
|
||||||
|
require(bytes(profileImage).length > 0, "Invalid profile image");
|
||||||
|
// ... rest of the function
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### [L-02] Gas Optimization Issues => not needed
|
||||||
|
**Contracts**: All
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
- Storage of full profile data on-chain is expensive
|
||||||
|
- Redundant storage of data that could be emitted as events
|
||||||
|
- Inefficient string handling in profile data
|
||||||
|
|
||||||
|
**Recommendation**:
|
||||||
|
- Consider using events for historical data
|
||||||
|
- Use off-chain storage for profile images
|
||||||
|
- Optimize string handling and storage patterns
|
||||||
|
|
||||||
|
### [L-03] Missing Events for Critical Operations => not needed
|
||||||
|
**Contracts**: All
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
Some state-changing operations lack event emissions, making it difficult to track changes off-chain.
|
||||||
|
|
||||||
|
**Recommendation**:
|
||||||
|
Add events for all significant state changes.
|
||||||
|
|
||||||
|
## Informational Findings
|
||||||
|
|
||||||
|
### [I-01] Documentation Gaps => not needed
|
||||||
|
**Contracts**: All
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
- Limited NatSpec documentation
|
||||||
|
- Missing function parameter documentation
|
||||||
|
- Unclear error messages
|
||||||
|
|
||||||
|
**Recommendation**:
|
||||||
|
Improve documentation coverage and clarity.
|
||||||
|
|
||||||
|
### [I-02] Version Control => not needed
|
||||||
|
**Contracts**: All
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
Contracts use `^0.8.19` which allows for minor version updates.
|
||||||
|
|
||||||
|
**Recommendation**:
|
||||||
|
Pin to exact version: `pragma solidity 0.8.19;`
|
||||||
@ -1,5 +1,7 @@
|
|||||||
# Blocked User Can Call `SoulboundProfileNFT::mintProfile` Again
|
# Blocked User Can Call `SoulboundProfileNFT::mintProfile` Again
|
||||||
|
|
||||||
|
## A valid finding but the actual severity is Medium instead of High. written by myself.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Due to missing blocked profile handling, any blocked profile can call `SoulboundProfileNFT::mintProfile` again to mint and take part on the system. The purpose of blocking a profile no longer valid, since they could mint a new profile and continue interacting with existing `LikeRegistry` and `MultiSig`.
|
Due to missing blocked profile handling, any blocked profile can call `SoulboundProfileNFT::mintProfile` again to mint and take part on the system. The purpose of blocking a profile no longer valid, since they could mint a new profile and continue interacting with existing `LikeRegistry` and `MultiSig`.
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
# H-02. User Fund Sent to LikeRegistry::likeUser is Never Accounted and Make `LikeRegistry::matchRewards` Always Return Zero
|
# H-02. User Fund Sent to LikeRegistry::likeUser is Never Accounted and Make `LikeRegistry::matchRewards` Always Return Zero
|
||||||
|
|
||||||
|
## This is a valid finding. Written by myself.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Due to miscalculation in the `LikeRegistry::matchRewards`, any deployed MultisigWallet will contain 0 fund, and also fees for the protocol is always 0 as well. User funds sent through `LikeRegistry::likeUser` is never being accounted to the user. It leads to the created multisigWallet has no fund in it, and user lose their fund.
|
Due to miscalculation in the `LikeRegistry::matchRewards`, any deployed MultisigWallet will contain 0 fund, and also fees for the protocol is always 0 as well. User funds sent through `LikeRegistry::likeUser` is never being accounted to the user. It leads to the created multisigWallet has no fund in it, and user lose their fund.
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
# Fund Sent Directly to `LikeRegistry` Will Not be Able to Withdraw by Anyone
|
# Fund Sent Directly to `LikeRegistry` Will Not be Able to Withdraw by Anyone
|
||||||
|
|
||||||
|
## This is an invalid low finding because its user mistake. Written by myself.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Due to missing handling of received funds which transferred directly to the contract address, it can lead to the funds unable to withdraw. Both user and the contract owner are unable to withdraw the fund being sent directly to the contract lead to a loss of fund.
|
Due to missing handling of received funds which transferred directly to the contract address, it can lead to the funds unable to withdraw. Both user and the contract owner are unable to withdraw the fund being sent directly to the contract lead to a loss of fund.
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
# Fund Locked in MultiSig when Match with a Blocked Profile
|
# Fund Locked in MultiSig when Match with a Blocked Profile
|
||||||
|
|
||||||
|
## This is an invalid high finding because of design choice. Written by myself.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
A profile can be blocked by the owner through `SoulboundProfileNFT::blockProfile`. But the fact that the profile is being blocked, doesn't change anything on the MultiSIgWallet. Someone who match up with a blocked profile now in the risk of their fund will never be able to withdraw. There is no other way to withdraw fund from MultiSigWallet other than both of the owner approve the transaction.
|
A profile can be blocked by the owner through `SoulboundProfileNFT::blockProfile`. But the fact that the profile is being blocked, doesn't change anything on the MultiSIgWallet. Someone who match up with a blocked profile now in the risk of their fund will never be able to withdraw. There is no other way to withdraw fund from MultiSigWallet other than both of the owner approve the transaction.
|
||||||
|
|||||||
80
audit/M-01.md
Normal file
80
audit/M-01.md
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
# M-01. `SoulboundProfileNFT::blockProfile` make it possible to recreate the profile
|
||||||
|
|
||||||
|
## This is the final report, not submitted by me.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The `SoulboundProfileNFT::blockProfile` function uses `delete profileToToken[blockAddress]`, which resets `profileToToken[blockAddress]` to `0`. Since the mintProfile function checks for an existing profile by verifying that `profileToToken[msg.sender] == 0`, a blocked account can be recreated by simply minting a new profile. This behavior bypasses the intended permanent block functionality.
|
||||||
|
|
||||||
|
## Vulnerability Details
|
||||||
|
|
||||||
|
By deleting the mapping entry for a blocked account, the contract inadvertently allows a new mintProfile call to pass the check `require(profileToToken[msg.sender] == 0, "Profile already exists")`. Essentially, once an account is blocked, its associated mapping entry is cleared, so the condition to identify an account with an existing profile is no longer met. This loophole enables a blocked account to recreate its profile, undermining the purpose of blocking.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
A blocked account, which should be permanently barred from engaging with the platform, can circumvent this restriction by re-minting its profile.
|
||||||
|
The integrity of the platform is compromised, as blocked users could regain access and potentially perform further malicious actions.
|
||||||
|
|
||||||
|
## POC
|
||||||
|
|
||||||
|
```
|
||||||
|
function testRecereationOfBlockedAccount() public {
|
||||||
|
|
||||||
|
// Alice mints a profile successfully
|
||||||
|
vm.prank(user);
|
||||||
|
soulboundNFT.mintProfile("Alice", 18, "ipfs://profileImageAlice");
|
||||||
|
|
||||||
|
// Owner blocks Alice's account, which deletes Alice profile mapping
|
||||||
|
vm.prank(owner);
|
||||||
|
soulboundNFT.blockProfile(user);
|
||||||
|
|
||||||
|
// The blocked user (Alice) attempts to mint a new profile.
|
||||||
|
// Due to the reset mapping value (0), the require check is bypassed.
|
||||||
|
vm.prank(user);
|
||||||
|
soulboundNFT.mintProfile("Alice", 18, "ipfs://profileImageAlice");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tools Used
|
||||||
|
|
||||||
|
* Foundry: Utilized for testing the contract, including validating the minting and blocking behavior.
|
||||||
|
* Manual Code Review: An analysis of the Solidity code confirmed that the delete operation resets the mapping value, creating the vulnerability.
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
* When blocking an account, implement a mechanism to permanently mark that address as blocked rather than simply deleting an entry. For example, maintain a separate mapping (e.g., isBlocked) to record blocked accounts, and update mintProfile to check if an account is permanently barred from minting: Example modification:
|
||||||
|
|
||||||
|
```
|
||||||
|
+ mapping(address => bool) public isBlocked;
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
function mintProfile(string memory name, uint8 age, string memory profileImage) external {
|
||||||
|
+ require(!isBlocked[msg.sender], "Account is permanently blocked");
|
||||||
|
require(profileToToken[msg.sender] == 0, "Profile already exists");
|
||||||
|
|
||||||
|
uint256 tokenId = ++_nextTokenId;
|
||||||
|
_safeMint(msg.sender, tokenId);
|
||||||
|
|
||||||
|
// Store metadata on-chain
|
||||||
|
_profiles[tokenId] = Profile(name, age, profileImage);
|
||||||
|
profileToToken[msg.sender] = tokenId;
|
||||||
|
|
||||||
|
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
function blockProfile(address blockAddress) external onlyOwner {
|
||||||
|
uint256 tokenId = profileToToken[blockAddress];
|
||||||
|
require(tokenId != 0, "No profile found");
|
||||||
|
|
||||||
|
_burn(tokenId);
|
||||||
|
delete profileToToken[blockAddress];
|
||||||
|
delete _profiles[tokenId];
|
||||||
|
|
||||||
|
+ isBlocked[blockAddress] = true;
|
||||||
|
|
||||||
|
emit ProfileBurned(blockAddress, tokenId);
|
||||||
|
}
|
||||||
|
```
|
||||||
265
audit/M-02.md
Normal file
265
audit/M-02.md
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
# M-02. Logic flaw in `LikeRegistry::matchRewards` can lead to users getting free dates
|
||||||
|
|
||||||
|
## Valid medium from someone else.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`LikeRegistry::matchRewards` resets the matched users' `userBalances`. However, if a previously matched user gets liked and matched later on, the share multisig wallet will contain no funds from him. Technically, this scenario isn't possible due to a bug where `userBalances` is never updated with user funds, but the flawed logic is still there.
|
||||||
|
|
||||||
|
## Vulnerability Details
|
||||||
|
|
||||||
|
Upon a match, `LikeRegistry::matchRewards` gets called.
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
function matchRewards(address from, address to) internal {
|
||||||
|
uint256 matchUserOne = userBalances[from];
|
||||||
|
uint256 matchUserTwo = userBalances[to];
|
||||||
|
// [1]
|
||||||
|
userBalances[from] = 0;
|
||||||
|
userBalances[to] = 0;
|
||||||
|
|
||||||
|
// [2]
|
||||||
|
uint256 totalRewards = matchUserOne + matchUserTwo;
|
||||||
|
// [3]
|
||||||
|
uint256 matchingFees = (totalRewards * FIXEDFEE) / 100;
|
||||||
|
uint256 rewards = totalRewards - matchingFees;
|
||||||
|
totalFees += matchingFees;
|
||||||
|
|
||||||
|
// Deploy a MultiSig contract for the matched users
|
||||||
|
MultiSigWallet multiSigWallet = new MultiSigWallet(from, to);
|
||||||
|
|
||||||
|
// [4]
|
||||||
|
// Send ETH to the deployed multisig wallet
|
||||||
|
(bool success,) = payable(address(multiSigWallet)).call{value: rewards}("");
|
||||||
|
require(success, "Transfer failed");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`matchRewards` will collect (`[2]`) all their previous like payments (minus a 10% fee `[3]`) and finally create a shared multisig wallet between the two users, which they can access for their first date (`[4]`). Note how `userBalances` is reset at `[1]`. Imagine the following scenario:
|
||||||
|
|
||||||
|
1. bob likes alice
|
||||||
|
* `userBalance[bob] += 1 ETH`
|
||||||
|
2. bob likes angie
|
||||||
|
* `userBalance[bob] += 1 ETH`
|
||||||
|
3. alice likes bob (match)
|
||||||
|
* `userBalance[alice] += 1 ETH`
|
||||||
|
* reset of `userBalance[alice]` and `userBalance[bob]`
|
||||||
|
4. angie likes alex
|
||||||
|
* `userBalance[angie] += 1 ETH`
|
||||||
|
5. angie likes tony
|
||||||
|
* `userBalance[angie] += 1 ETH`
|
||||||
|
6. angie likes bob (match)
|
||||||
|
* `userBalance[angie] += 1 ETH` (total of 3 ETH)
|
||||||
|
* `userBalance[bob]` is reset from bob's previous match with alice
|
||||||
|
7. shared multisig wallet is created using only angie's funds
|
||||||
|
|
||||||
|
## Proof of Concept
|
||||||
|
|
||||||
|
To demonstrate the aforementioned scenario, apply the following patch in `LikeRegistry::likeUser` in order to update `userBalances` properly,
|
||||||
|
|
||||||
|
```
|
||||||
|
function likeUser(address liked) external payable {
|
||||||
|
require(msg.value >= 1 ether, "Must send at least 1 ETH");
|
||||||
|
require(!likes[msg.sender][liked], "Already liked");
|
||||||
|
require(msg.sender != liked, "Cannot like yourself");
|
||||||
|
require(
|
||||||
|
profileNFT.profileToToken(msg.sender) != 0,
|
||||||
|
"Must have a profile NFT"
|
||||||
|
);
|
||||||
|
require(
|
||||||
|
profileNFT.profileToToken(liked) != 0,
|
||||||
|
"Liked user must have a profile NFT"
|
||||||
|
);
|
||||||
|
|
||||||
|
+ userBalances[msg.sender] += msg.value;
|
||||||
|
|
||||||
|
likes[msg.sender][liked] = true;
|
||||||
|
emit Liked(msg.sender, liked);
|
||||||
|
|
||||||
|
// Check if mutual like
|
||||||
|
if (likes[liked][msg.sender]) {
|
||||||
|
matches[msg.sender].push(liked);
|
||||||
|
matches[liked].push(msg.sender);
|
||||||
|
emit Matched(msg.sender, liked);
|
||||||
|
matchRewards(liked, msg.sender);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
as well as a few logs in `LikeRegistry::matchRewards`:
|
||||||
|
|
||||||
|
```
|
||||||
|
function matchRewards(address from, address to) internal {
|
||||||
|
uint256 matchUserOne = userBalances[from];
|
||||||
|
uint256 matchUserTwo = userBalances[to];
|
||||||
|
@> console.log("[LikeRegistry::matchRewards] matchUserOne:", matchUserOne);
|
||||||
|
@> console.log("[LikeRegistry::matchRewards] matchUserTwo:", matchUserTwo);
|
||||||
|
userBalances[from] = 0;
|
||||||
|
userBalances[to] = 0;
|
||||||
|
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// Deploy a MultiSig contract for the matched users
|
||||||
|
MultiSigWallet multiSigWallet = new MultiSigWallet(payable(address(this)), from, to);
|
||||||
|
// Send ETH to the deployed multisig wallet
|
||||||
|
(bool success, ) = payable(address(multiSigWallet)).call{
|
||||||
|
value: rewards
|
||||||
|
}("");
|
||||||
|
require(success, "Transfer failed");
|
||||||
|
@> console.log("[LikeRegistry::matchRewards] multiSigWallet balance:", address(multiSigWallet).balance);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Finally, place `test_UserCanGetFreeDates` in `testSoulboundProfileNFT.t.sol`:
|
||||||
|
|
||||||
|
```
|
||||||
|
function test_UserCanGetFreeDates() public {
|
||||||
|
address bob = makeAddr("bob");
|
||||||
|
address alice = makeAddr("alice");
|
||||||
|
address angie = makeAddr("angie");
|
||||||
|
address alex = makeAddr("alex");
|
||||||
|
address tony = makeAddr("tony");
|
||||||
|
|
||||||
|
vm.deal(bob, 10 ether);
|
||||||
|
vm.deal(alice, 10 ether);
|
||||||
|
vm.deal(angie, 10 ether);
|
||||||
|
|
||||||
|
// mint a profile NFT for bob
|
||||||
|
vm.prank(bob);
|
||||||
|
soulboundNFT.mintProfile("Bob", 25, "ipfs://profileImage");
|
||||||
|
|
||||||
|
// mint a profile NFT for alice
|
||||||
|
vm.prank(alice);
|
||||||
|
soulboundNFT.mintProfile("Alice", 25, "ipfs://profileImage");
|
||||||
|
|
||||||
|
// mint a profile NFT for angie
|
||||||
|
vm.prank(angie);
|
||||||
|
soulboundNFT.mintProfile("Angie", 25, "ipfs://profileImage");
|
||||||
|
|
||||||
|
// mint a profile NFT for alex
|
||||||
|
vm.prank(alex);
|
||||||
|
soulboundNFT.mintProfile("Alex", 25, "ipfs://profileImage");
|
||||||
|
|
||||||
|
// mint a profile NFT for tony
|
||||||
|
vm.prank(tony);
|
||||||
|
soulboundNFT.mintProfile("Tony", 25, "ipfs://profileImage");
|
||||||
|
|
||||||
|
// bob <3 alice
|
||||||
|
vm.prank(bob);
|
||||||
|
likeRegistry.likeUser{value: 1 ether}(alice);
|
||||||
|
assertTrue(likeRegistry.likes(bob, alice));
|
||||||
|
|
||||||
|
// bob <3 angie
|
||||||
|
vm.prank(bob);
|
||||||
|
likeRegistry.likeUser{value: 1 ether}(angie);
|
||||||
|
assertTrue(likeRegistry.likes(bob, angie));
|
||||||
|
|
||||||
|
console.log("====== FIRST MATCH ======");
|
||||||
|
// alice <3 bob (match)
|
||||||
|
vm.prank(alice);
|
||||||
|
likeRegistry.likeUser{value: 1 ether}(bob);
|
||||||
|
assertTrue(likeRegistry.likes(alice, bob));
|
||||||
|
|
||||||
|
// angie <3 alex
|
||||||
|
vm.prank(angie);
|
||||||
|
likeRegistry.likeUser{value: 1 ether}(alex);
|
||||||
|
assertTrue(likeRegistry.likes(angie, alex));
|
||||||
|
|
||||||
|
// angie <3 tony
|
||||||
|
vm.prank(angie);
|
||||||
|
likeRegistry.likeUser{value: 1 ether}(tony);
|
||||||
|
assertTrue(likeRegistry.likes(angie, tony));
|
||||||
|
|
||||||
|
console.log("\n\n====== SECOND MATCH ======");
|
||||||
|
// angie <3 bob (match)
|
||||||
|
vm.prank(angie);
|
||||||
|
likeRegistry.likeUser{value: 1 ether}(bob);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
and run the test:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ forge test --mt test_UserCanGetFreeDates -vvv
|
||||||
|
Ran 1 test for test/testSoulboundProfileNFT.t.sol:SoulboundProfileNFTTest
|
||||||
|
[PASS] test_UserCanGetFreeDates() (gas: 2274150)
|
||||||
|
Logs:
|
||||||
|
====== FIRST MATCH ======
|
||||||
|
[LikeRegistry::matchRewards] matchUserOne: 2000000000000000000
|
||||||
|
[LikeRegistry::matchRewards] matchUserTwo: 1000000000000000000
|
||||||
|
[LikeRegistry::matchRewards] totalFees: 300000000000000000
|
||||||
|
[LikeRegistry::matchRewards] multiSigWallet balance: 2700000000000000000
|
||||||
|
|
||||||
|
|
||||||
|
====== SECOND MATCH ======
|
||||||
|
[LikeRegistry::matchRewards] matchUserOne: 0
|
||||||
|
[LikeRegistry::matchRewards] matchUserTwo: 3000000000000000000
|
||||||
|
[LikeRegistry::matchRewards] totalFees: 600000000000000000
|
||||||
|
[LikeRegistry::matchRewards] multiSigWallet balance: 2700000000000000000
|
||||||
|
|
||||||
|
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 7.57ms (1.93ms CPU time)
|
||||||
|
|
||||||
|
Ran 1 test suite in 139.45ms (7.57ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
|
||||||
|
```
|
||||||
|
|
||||||
|
Note how on the second match (between angie and bob), `matchUserOne` (which corresponds to bob) is 0. It was reset upon his match with alice.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
Users can get free dates. The impact is low since this bug isn't technically feasible due to a bug in the `LikeRegistry` contract where `userBalances` isn't properly updated with user payments. The logic flaw remains though.
|
||||||
|
|
||||||
|
## Tools used
|
||||||
|
|
||||||
|
Manual review, tests
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
Consider grouping ETH-like payments on a per-like basis instead of all together.
|
||||||
|
|
||||||
|
```
|
||||||
|
contract LikeRegistry is Ownable {
|
||||||
|
|
||||||
|
// ...
|
||||||
|
|
||||||
|
mapping(address => mapping(address => bool)) public likes;
|
||||||
|
mapping(address => address[]) public matches;
|
||||||
|
- mapping(address => uint256) public userBalances;
|
||||||
|
+ mapping(address => mapping(address => uint256)) public userBalances;
|
||||||
|
|
||||||
|
|
||||||
|
function likeUser(address liked) external payable {
|
||||||
|
require(msg.value >= 1 ether, "Must send at least 1 ETH");
|
||||||
|
require(!likes[msg.sender][liked], "Already liked");
|
||||||
|
require(msg.sender != liked, "Cannot like yourself");
|
||||||
|
require(
|
||||||
|
profileNFT.profileToToken(msg.sender) != 0,
|
||||||
|
"Must have a profile NFT"
|
||||||
|
);
|
||||||
|
require(
|
||||||
|
profileNFT.profileToToken(liked) != 0,
|
||||||
|
"Liked user must have a profile NFT"
|
||||||
|
);
|
||||||
|
|
||||||
|
+ userBalances[msg.sender][liked] += msg.value;
|
||||||
|
|
||||||
|
likes[msg.sender][liked] = true;
|
||||||
|
emit Liked(msg.sender, liked);
|
||||||
|
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchRewards(address from, address to) internal {
|
||||||
|
- uint256 matchUserOne = userBalances[from];
|
||||||
|
- uint256 matchUserTwo = userBalances[to];
|
||||||
|
+ uint256 matchUserOne = userBalances[from][to];
|
||||||
|
+ uint256 matchUserTwo = userBalances[to][from];
|
||||||
|
- userBalances[from][to] = 0;
|
||||||
|
- userBalances[to][from] = 0;
|
||||||
|
+ userBalances[from][to] = 0;
|
||||||
|
+ userBalances[to][from] = 0;
|
||||||
|
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
83
audit/M-03.md
Normal file
83
audit/M-03.md
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# M-03. App owner can have users' funds locked by blocking them
|
||||||
|
|
||||||
|
## Valid medium finding by someone else
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
App owner can block users at will, causing users to have their funds locked.
|
||||||
|
|
||||||
|
## Vulnerability Details
|
||||||
|
|
||||||
|
`SoulboundProfileNFT::blockProfile` can block any app's user at will.
|
||||||
|
|
||||||
|
```
|
||||||
|
/// @notice App owner can block users
|
||||||
|
function blockProfile(address blockAddress) external onlyOwner {
|
||||||
|
uint256 tokenId = profileToToken[blockAddress];
|
||||||
|
require(tokenId != 0, "No profile found");
|
||||||
|
|
||||||
|
_burn(tokenId);
|
||||||
|
delete profileToToken[blockAddress];
|
||||||
|
delete _profiles[tokenId];
|
||||||
|
|
||||||
|
emit ProfileBurned(blockAddress, tokenId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Proof of Concept
|
||||||
|
|
||||||
|
The following code demonstrates the scenario where the app owner blocks `bob` and he is no longer able to call `LikeRegistry::likeUser`. Since the contract gives no posibility of fund withdrawal, `bob`'s funds are now locked.
|
||||||
|
|
||||||
|
Place `test_blockProfileAbuseCanCauseFundLoss` in `testSoulboundProfileNFT.t.sol`:
|
||||||
|
|
||||||
|
```
|
||||||
|
function test_blockProfileAbuseCanCauseFundLoss() public {
|
||||||
|
vm.deal(bob, 10 ether);
|
||||||
|
vm.deal(alice, 10 ether);
|
||||||
|
|
||||||
|
// mint a profile NFT for bob
|
||||||
|
vm.prank(bob);
|
||||||
|
soulboundNFT.mintProfile("Bob", 25, "ipfs://profileImage");
|
||||||
|
|
||||||
|
// mint a profile NFT for Alice
|
||||||
|
vm.prank(alice);
|
||||||
|
soulboundNFT.mintProfile("Alice", 25, "ipfs://profileImage");
|
||||||
|
|
||||||
|
// alice <3 bob
|
||||||
|
vm.prank(alice);
|
||||||
|
likeRegistry.likeUser{value: 1 ether}(bob);
|
||||||
|
|
||||||
|
vm.startPrank(owner);
|
||||||
|
soulboundNFT.blockProfile(bob);
|
||||||
|
assertEq(soulboundNFT.profileToToken(msg.sender), 0);
|
||||||
|
|
||||||
|
vm.startPrank(bob);
|
||||||
|
vm.expectRevert("Must have a profile NFT");
|
||||||
|
// bob is no longer able to like a user, as his profile NFT is deleted
|
||||||
|
// his funds are effectively locked
|
||||||
|
likeRegistry.likeUser{value: 1 ether}(alice);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
And run the test:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ forge test --mt test_blockProfileAbuseCanCauseFundLoss
|
||||||
|
Ran 1 test for test/testSoulboundProfileNFT.t.sol:SoulboundProfileNFTTest
|
||||||
|
[PASS] test_blockProfileAbuseCanCauseFundLoss() (gas: 326392)
|
||||||
|
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.42ms (219.63µs CPU time)
|
||||||
|
|
||||||
|
Ran 1 test suite in 140.90ms (1.42ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
App users can have their funds locked, as well as miss out on potential dates.
|
||||||
|
|
||||||
|
## Tools used
|
||||||
|
|
||||||
|
Manual review, tests
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
Add a voting mechanism to prevent abuse and/or centralization of the feature.
|
||||||
104
audit/M-04.md
Normal file
104
audit/M-04.md
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
# M-04. Reentrancy in `SoulboundProfileNft::mintProfile` allows minting multiple NFTs per address, which disrupts protocol expectations
|
||||||
|
|
||||||
|
## Approved finding by someone else
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
In `mintProfile`, the internal `_safeMint` function is called before updating the contract state (`_profiles[tokenId]` and `profileToToken[msg.sender]`). This violates CEI, as `_safeMint` calls an internal function that could invoke an external contract if `msg.sender` is a contract with a malicious `onERC721Received` implementation.
|
||||||
|
|
||||||
|
Source Code:
|
||||||
|
|
||||||
|
```
|
||||||
|
function mintProfile(string memory name, uint8 age, string memory profileImage) external {
|
||||||
|
require(profileToToken[msg.sender] == 0, "Profile already exists");
|
||||||
|
|
||||||
|
uint256 tokenId = ++_nextTokenId;
|
||||||
|
_safeMint(msg.sender, tokenId);
|
||||||
|
|
||||||
|
// Store metadata on-chain
|
||||||
|
_profiles[tokenId] = Profile(name, age, profileImage);
|
||||||
|
profileToToken[msg.sender] = tokenId;
|
||||||
|
|
||||||
|
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vulnerability Details
|
||||||
|
|
||||||
|
Copy this test and auxiliary contract in the unit test suite to prove that an attacker can mint multiple NFTs:
|
||||||
|
|
||||||
|
```
|
||||||
|
function testReentrancyMultipleNft() public {
|
||||||
|
MaliciousContract maliciousContract = new MaliciousContract(
|
||||||
|
address(soulboundNFT)
|
||||||
|
);
|
||||||
|
vm.prank(address(maliciousContract));
|
||||||
|
MaliciousContract(maliciousContract).attack();
|
||||||
|
assertEq(soulboundNFT.balanceOf(address(maliciousContract)), 2);
|
||||||
|
assertEq(soulboundNFT.profileToToken(address(maliciousContract)), 1);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
contract MaliciousContract {
|
||||||
|
SoulboundProfileNFT soulboundNFT;
|
||||||
|
uint256 counter;
|
||||||
|
|
||||||
|
constructor(address _soulboundNFT) {
|
||||||
|
soulboundNFT = SoulboundProfileNFT(_soulboundNFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Malicious reentrancy attack
|
||||||
|
function attack() external {
|
||||||
|
soulboundNFT.mintProfile("Evil", 99, "malicious.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Malicious onERC721Received function
|
||||||
|
function onERC721Received(
|
||||||
|
address operator,
|
||||||
|
address from,
|
||||||
|
uint256 tokenId,
|
||||||
|
bytes calldata data
|
||||||
|
) external returns (bytes4) {
|
||||||
|
// Reenter the mintProfile function
|
||||||
|
if (counter == 0) {
|
||||||
|
counter++;
|
||||||
|
soulboundNFT.mintProfile("EvilAgain", 100, "malicious2.png");
|
||||||
|
}
|
||||||
|
return 0x150b7a02;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
The attacker could end up having multiple NTFs, but only one profile. This is because the `mintProfile`function resets the `profileToToken`mapping each time. At the end, the attacker will have only one profile connecting with one token ID with the information of the first mint.
|
||||||
|
|
||||||
|
I consider that the severity is Low because the `LikeRegistry`contract works with the token IDs, not the NFTs. So, the impact will be a disruption in the relation of the amount of NTFs and the amount of profiles.
|
||||||
|
|
||||||
|
## Tools Used
|
||||||
|
|
||||||
|
Foundry
|
||||||
|
|
||||||
|
Slither
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
To follow CEI properly, move `_safeMint` to the end:
|
||||||
|
|
||||||
|
```
|
||||||
|
function mintProfile(string memory name, uint8 age, string memory profileImage) external {
|
||||||
|
require(profileToToken[msg.sender] == 0, "Profile already exists");
|
||||||
|
|
||||||
|
uint256 tokenId = ++_nextTokenId;
|
||||||
|
- _safeMint(msg.sender, tokenId);
|
||||||
|
|
||||||
|
// Store metadata on-chain
|
||||||
|
_profiles[tokenId] = Profile(name, age, profileImage);
|
||||||
|
profileToToken[msg.sender] = tokenId;
|
||||||
|
|
||||||
|
+ _safeMint(msg.sender, tokenId);
|
||||||
|
|
||||||
|
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
|
||||||
|
}
|
||||||
|
```
|
||||||
128
retrospective.md
Normal file
128
retrospective.md
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
# Audit Findings Retrospective
|
||||||
|
|
||||||
|
## Analysis of Your Findings
|
||||||
|
|
||||||
|
### Finding Overview
|
||||||
|
1. H-01: Blocked User Can Call `SoulboundProfileNFT::mintProfile` Again
|
||||||
|
- Originally marked as High severity
|
||||||
|
- Actually Medium severity
|
||||||
|
- Valid finding but severity overestimated
|
||||||
|
|
||||||
|
2. H-02: User Fund Sent to LikeRegistry::likeUser is Never Accounted
|
||||||
|
- High severity
|
||||||
|
- Valid finding
|
||||||
|
- Well documented with proper severity assessment
|
||||||
|
|
||||||
|
## Areas for Improvement
|
||||||
|
|
||||||
|
1. **Severity Assessment**
|
||||||
|
- H-01 was overestimated as High when it should be Medium
|
||||||
|
- Consider the following for severity assessment:
|
||||||
|
- Immediate financial impact
|
||||||
|
- Likelihood of exploitation
|
||||||
|
- Complexity of the attack
|
||||||
|
- Required preconditions
|
||||||
|
|
||||||
|
2. **Impact Analysis**
|
||||||
|
- Could expand more on the real-world implications
|
||||||
|
- Consider including specific attack scenarios
|
||||||
|
- Quantify potential losses where possible
|
||||||
|
|
||||||
|
3. **Alternative Attack Vectors**
|
||||||
|
- Could explore more variations of the attacks
|
||||||
|
- Consider combining vulnerabilities for compound effects
|
||||||
|
- Analyze potential bypass methods for proposed fixes
|
||||||
|
|
||||||
|
## Recommendations for Future Findings
|
||||||
|
|
||||||
|
1. **Severity Classification**
|
||||||
|
- Use CVSS scoring system or similar framework
|
||||||
|
- Document severity reasoning more explicitly
|
||||||
|
- Consider both impact and likelihood
|
||||||
|
|
||||||
|
2. **Documentation Enhancement**
|
||||||
|
- Include diagrams for complex attack flows
|
||||||
|
- Add more real-world exploitation scenarios
|
||||||
|
- Document any assumptions made
|
||||||
|
|
||||||
|
3. **Testing Methodology**
|
||||||
|
- Include more edge cases in POCs
|
||||||
|
- Test fixes against multiple attack vectors
|
||||||
|
- Document test coverage
|
||||||
|
|
||||||
|
## Learning from Other Auditors' Findings
|
||||||
|
|
||||||
|
### 1. Deeper State Analysis (from M-01)
|
||||||
|
- Other auditors identified the same issue with blocked profiles but approached it differently
|
||||||
|
- They focused on the root cause (state management) rather than just the symptom
|
||||||
|
- Key learning: Always analyze how state changes affect the entire system lifecycle
|
||||||
|
- Their solution with `isBlocked` mapping is more robust and gas-efficient
|
||||||
|
|
||||||
|
### 2. Complex Scenario Modeling (from M-02)
|
||||||
|
- Impressive multi-step attack scenario involving multiple users
|
||||||
|
- Demonstrated deep understanding of business logic flaws
|
||||||
|
- Key learnings:
|
||||||
|
- Consider how different user interactions can be combined
|
||||||
|
- Test complex user interaction sequences
|
||||||
|
- Think about how state resets affect future interactions
|
||||||
|
- Pay attention to accounting mechanisms and balance tracking
|
||||||
|
|
||||||
|
### 3. Quality of Documentation
|
||||||
|
- Other auditors excelled in:
|
||||||
|
- Clear step-by-step attack scenarios
|
||||||
|
- Detailed proof of concepts with multiple actors
|
||||||
|
- Well-structured test cases that prove the vulnerability
|
||||||
|
- Thorough explanation of the root cause
|
||||||
|
|
||||||
|
### 4. Advanced Testing Techniques
|
||||||
|
- Use of descriptive console.log statements in POCs
|
||||||
|
- Creation of multiple test accounts with meaningful names
|
||||||
|
- Systematic approach to testing complex scenarios
|
||||||
|
- Clear separation of setup, execution, and verification in tests
|
||||||
|
|
||||||
|
### 5. Recommendations Quality
|
||||||
|
- Solutions consider:
|
||||||
|
- Gas efficiency
|
||||||
|
- State management
|
||||||
|
- Future extensibility
|
||||||
|
- Clear upgrade paths
|
||||||
|
|
||||||
|
### 6. Centralization Risk Analysis (from M-03)
|
||||||
|
- Identified important centralization risks in permissioned functions
|
||||||
|
- Considered user fund safety in relation to admin powers
|
||||||
|
- Key learnings:
|
||||||
|
- Always analyze privileged functions for potential abuse
|
||||||
|
- Consider user fund safety in relation to admin actions
|
||||||
|
- Propose decentralized alternatives (e.g., voting mechanisms)
|
||||||
|
- Think about user protection mechanisms
|
||||||
|
|
||||||
|
### 7. Smart Contract Security Patterns (from M-04)
|
||||||
|
- Excellent identification of CEI (Checks-Effects-Interactions) pattern violation
|
||||||
|
- Thorough understanding of reentrancy vectors in NFT minting
|
||||||
|
- Key learnings:
|
||||||
|
- Always follow established security patterns
|
||||||
|
- Consider callback functions as potential attack vectors
|
||||||
|
- Pay attention to state updates ordering
|
||||||
|
- Think about contract-to-contract interactions
|
||||||
|
|
||||||
|
### 8. Testing Innovation
|
||||||
|
From M-03 and M-04:
|
||||||
|
- Creation of specialized malicious contracts for testing
|
||||||
|
- Use of vm.prank and vm.startPrank effectively
|
||||||
|
- Clear test naming that describes the attack scenario
|
||||||
|
- Effective use of assertions to prove vulnerability impact
|
||||||
|
|
||||||
|
## Additional Key Takeaways
|
||||||
|
|
||||||
|
6. **Consider Centralization**: Always analyze privileged functions for potential abuse and propose decentralized alternatives
|
||||||
|
7. **Follow Security Patterns**: Adhere to established patterns like CEI and document any necessary deviations
|
||||||
|
8. **Test Edge Cases**: Create specialized contracts and scenarios to test security assumptions
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Remember to:
|
||||||
|
- Double-check severity ratings against standard frameworks
|
||||||
|
- Expand impact analysis with real-world scenarios
|
||||||
|
- Consider more complex attack vectors
|
||||||
|
- Test fixes thoroughly
|
||||||
|
- Document assumptions and limitations
|
||||||
Loading…
Reference in New Issue
Block a user