This article examines a critical aspect of smart contract security: the risk of having administrator privileges that allow for the draining of user funds, commonly referred to as a "rug pull."
This is part of the "Solodit Checklist Explained" series. You can find the previous articles here:
For the best experience, open a tab with the Solodit checklist to refer to it as you read.
Examples are available on my GitHub here.
Literally, a "rug pull" is the act of abruptly pulling a rug from under someone, causing them to fall unexpectedly.
In decentralized finance (DeFi), a rug pull refers to a scam in which developers promote a new token or platform, only to suddenly drain the liquidity pool (LP) or sell their holdings in large quantities, causing the token’s value to plummet.
This is a form of "centralization risk." Centralization risk encompasses a wide range of vulnerabilities, such as a single entity controlling upgrades, pausing functions, or critical parameters. This article will focus specifically on fund-draining rug pulls, where administrators exploit their privileges to siphon funds deposited by users or held within the protocol.
Other types of centralization risks are addressed comprehensively in a separate category of the Solodit checklist, covering issues like pause switch control and parameter modification capabilities. Here, we focus solely on the direct theft of value through admin-controlled withdrawal functions.
Effective management of administrative access goes beyond writing secure code. It involves designing trust into the protocol.
This checklist item addresses a critical risk: an administrator's ability to withdraw assets, which can directly jeopardize user funds.
Administrative roles are essential for maintaining and upgrading protocols, but granting unrestricted access to assets creates a significant vulnerability. If an attacker gains control of the admin key, they can drain funds, effectively executing a "rug pull" by depleting the protocol's reserves. Even without malicious intent, a compromised key due to a security breach can lead to severe consequences, undermining the protocol’s reputation and user trust.
To mitigate this risk, smart contracts should be designed to minimize the impact of a compromised or malicious admin account. This involves limiting administrative privileges, enforcing strict checks on all admin actions, and implementing safeguards to prevent unilateral or immediate asset transfers. Administrative functions should be treated like a precise surgical procedure, necessary but carefully restricted to a secure, predefined scope.
A recent example of this vulnerability is the Zunami Protocol incident in May 2025, which resulted in a loss of $500,000 in zunUSD and zunETH collateral. According to rekt.news, this was not a sophisticated exploit involving flash loans or price manipulation. Rather, it was a simple abuse of an overly powerful admin function. An individual with "god-mode access" invoked the withdrawStuckToken()
function, emptying the vault’s contents.
This incident highlights that some of the most damaging "hacks" result from authorized administrators, whether malicious or compromised, misusing legitimate contract functions. The Zunami case reminds us that unsecured emergency functions can become tools for attackers if not properly protected.
Here's an example showing how a malicious or compromised admin could drain a vault, execute a rug pull:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract VulnerableVault {
address public admin;
// Maps user addresses to their deposited balances
mapping(address => uint256) public userBalances;
constructor() {
// The deployer of the contract becomes the admin
admin = msg.sender;
}
/// @notice Allows users to deposit Ether into the vault.
/// A 1% fee is applied to the deposit and sent to a fee pool.
function deposit() public payable {
require(msg.value > 0, "Deposit must be greater than zero");
// Calculate 1% fee. This fee theoretically belongs to the protocol.
uint256 fee = msg.value / 100; // 1% of deposited amount
uint256 amountToDeposit = msg.value - fee;
// User's balance is updated with their net deposit.
userBalances[msg.sender] += amountToDeposit;
// The 'fee' part of msg.value remains in the contract's total balance.
// It is not explicitly tracked in a separate variable, which is a design flaw
// if only fees should be withdrawable by admin.
}
/// @notice Allows users to withdraw their deposited funds.
/// @param amount The amount to withdraw.
function withdraw(uint256 amount) public {
require(userBalances[msg.sender] >= amount, "Insufficient balance");
// Decrement user's balance and transfer funds.
userBalances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
/// @notice Admin can withdraw *any* amount from the contract's balance.
/// This is the rug pull vulnerability! This simulates a `withdrawStuckToken()`-like function.
/// @param amount The amount to withdraw from the contract.
function adminRugPullWithdraw(uint256 amount) public {
require(msg.sender == admin, "Admin control required"); // Only admin can call.
// NO checks to ensure 'amount' is only from accrued fees.
// The admin can withdraw any amount up to the contract's total Ether balance (address(this).balance),
// effectively stealing user deposits, as user deposits also contribute to address(this).balance.
// This function treats all funds in the contract as if they are available for the admin to take.
require(address(this).balance >= amount, "Insufficient contract balance for withdrawal");
payable(admin).transfer(amount); // Transfer chosen amount to admin.
}
}
In this VulnerableVault
contract, the adminRugPullWithdraw
function allows the admin to withdraw any amount of Ether from the contract's total balance. This is a critical vulnerability. If the admin key is compromised, or if the individual controlling the key decides to act maliciously (as was questioned in the Zunami case regarding an "insider job"), they could use this function to drain all the Ether deposited in the vault, including user funds.
The require
statement only validates that the caller is the admin
, completely failing to validate the amount
against what should legitimately be withdrawable (e.g., only protocol fees, not user principal).
To mitigate this vulnerability and prevent a rug pull, consider these robust strategies:
Here's a revised version of the contract, mitigating the vulnerability by introducing internal fee tracking and restricting admin withdrawals to only those accrued fees:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SecuredVault {
address public admin;
// Maps user addresses to their deposited balances
mapping(address => uint256) public userBalances;
// This variable *only* accounts for fees that the admin can withdraw.
// It's explicitly separated from user balances.
uint256 public totalAccruedFees;
constructor() {
// The deployer of the contract becomes the admin
admin = msg.sender;
}
/// @notice Allows users to deposit Ether into the vault.
/// A 1% fee is applied to the deposit and sent to a fee pool.
function deposit() public payable {
require(msg.value > 0, "Deposit must be greater than zero");
// Calculate 1% fee
uint256 fee = msg.value / 100; // 1% of deposited amount
uint256 amountToDeposit = msg.value - fee;
userBalances[msg.sender] += amountToDeposit; // User's principal
totalAccruedFees += fee; // Accrue the fee for the protocol
}
/// @notice Allows users to withdraw their deposited funds.
/// @param amount The amount to withdraw.
function withdraw(uint256 amount) public {
require(userBalances[msg.sender] >= amount, "Insufficient balance");
userBalances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
/// @notice Admin can only withdraw the total accrued fees.
/// This prevents the rug pull as admin cannot touch user principal.
/// @param amount The amount of fees the admin wishes to withdraw.
function adminWithdrawFees(uint256 amount) public {
require(msg.sender == admin, "Admin control required");
// Crucial check: Ensure admin only withdraws what's available in fees,
// as tracked separately from user principal.
require(totalAccruedFees >= amount, "Insufficient accrued fees for withdrawal");
totalAccruedFees -= amount; // Deduct the withdrawn amount from available fees
payable(admin).transfer(amount); // Transfer only the requested fee amount to admin
}
}
In this improved SecuredVault
version, the adminWithdrawFees
function no longer directly interacts with user balances. User funds are protected as they are held separately from the totalAccruedFees
variable. This alleviates the risk of an admin privilege on the user principal, addressing the core issue seen in the Zunami attack.
Managing administrative privileges is essential for safeguarding security and maintaining user trust. Unrestricted access to assets can result in devastating losses, as demonstrated by incidents like the Zunami Protocol, where unchecked admin privileges led to the complete depletion of user funds. Adhering to this checklist item is critical to preventing such vulnerabilities.
Robust security requires proactive measures, including tightly controlled admin functions and well-designed safeguards to mitigate risks.
In the next article, we will examine another vital aspect of smart contract security: the Sandwich Attack.
Stay tuned for further insights on developing secure and resilient decentralized applications!