Solodit 清单解读:拒绝服务攻击 第 1 部分

  • cyfrin
  • 发布于 3天前
  • 阅读 267

本文详细介绍了如何在智能合约中防止拒绝服务(DoS)攻击,探讨了常见的攻击模式和安全编码实践,强调了拉取模式和最低交易额的必要性,以及黑名单功能可能带来的风险。文章通过代码示例清晰展现了这些关键点,并提出了实用的预防措施。

Hans

Solodit 检查清单解读(1):拒绝服务攻击第一部分

了解如何在智能合约中防止拒绝服务(DoS)攻击。探索常见模式、现实世界实例以及 DeFi 中的安全编码实践。

Solodit 检查清单解读:拒绝服务攻击(1)

好的,追求安全的小伙伴们!Hans 在此准备深入探讨“ Solodit 检查清单解读”的第一部分?

如果你是第一次参加这场盛宴,欢迎你!这个系列旨在将 Solodit 智能合约 审计检查清单中的380多个项目转化为你实际可以使用的内容。

前言(第0部分)中,我们搭建了背景,承诺带来充满实用例子和实战故事的旅程。

今天,我们将关注那些让安全专家在审计或比赛后面临困境的问题,因为这些问题普遍存在,却在代码深处往往被轻易忽视。

我说的就是 拒绝服务(DoS) 攻击!

我们将讨论三个检查清单项目:

  • SOL-AM-DOSA-1:是否遵循提款模式以防止拒绝服务?
  • SOL-AM-DOSA-2:是否强制执行最低交易金额?
  • SOL-AM-DOSA-3:协议如何处理黑名单功能Token?

所以,抓一杯你喜欢的增强脑力饮品,系好安全带,让我们来提升这个表演的节奏吧!为了获得最佳体验,打开一块Solodit检查清单作为参考。

为什么要担心 DoS 攻击?

DoS 攻击难道只是传统中心化服务器的问题?这是大公司需要担心的事情,对吧?错!

事实是,在去中心化金融( DeFi)中,DoS 攻击可能是毁灭性的。想象这样一个噩梦场景:你精心打造的质押合约,旨在丰厚奖励忠实用户,突然……它停止了。

它因为某个恶意行为者正 flooding 它的交易而停滞不前。用户无法提取他们的资金,整个系统变得令人沮丧无法使用。那么你的声誉呢?好吧,下降得比一只 rug pull 后的 memecoin 还要快(我们都知道那种感觉)。

DoS 攻击是利用代码中的漏洞,使你的智能合约无法使用,要么对特定用户,要么对所有人不适用。它们就像数字路障,阻碍合法用户访问你协议的良好意图服务!在信任为重的空间中,成功的 DoS 攻击可能会造成绝对灾难。

为了说明这一点,可以这样想:你倾注心血建立了一家美丽的咖啡店。这是世界上最好的咖啡店。但接着有人不停地 点了成千上万的空咖啡杯塞塞塞塞塞住整个系统,使得真实的客户无法获得他们的咖啡。真令人愤怒,对吧?这就是 DoS 攻击的本质!

拒绝服务(DOS)攻击:高层概述

DoS 攻击通常是关于 利用你智能合约逻辑中的设计缺陷。这不是暴力破解。它们使用微妙的操控来击败你。

下面是一般思路的细分:

  • 逻辑利用:这完全是关于寻找聪明(或者更准确地说,是恶意)方式来触发 状态 变化,以导致 回滚 的交易或无限循环,有效地冻结合约。这种情况发生得比你可能想象的还要频繁。

  • 资源耗尽:把它想象成公地悲剧与 gas 费用。它发生在攻击者涌进合约请求,消耗过多的 gas,使得合法用户往往承受高昂费用。记住,区块链上的每一个动作都需要 gas,而一波波交易会将这些费用抬升得天翻地覆。

现在我们简要介绍了高层视图(并且希望已经说服你 DoS 攻击确实是一个真实威胁),让我们深入探讨一些 缓解他们的具体方法

SOL-AM-DOSA-1: 是否遵循提款模式以防止拒绝服务?

翻译:你是否在提款时使用 “拉取”模式而不是“推送”模式

经典错误(我在审计比赛中见得多了)是推送 ETH 给用户在提款函数中。看起来很简单,对吧?大错特错!让我们看一些代码:

// 反模式:推送 ETH
function batchWithdraw() public {
    address[] memory users = getUsers(); // 一个获取用户的假想函数
    for (uint i = 0; i < users.length; i++) {
        uint amount = balances[users[i]];
        if (amount > 0) {
            balances[users[i]] = 0;
            (bool success, ) = users[i].call{value: amount}(""); // 潜在的 DoS!
            require(success, "转账失败"); // 如果任何转账失败,整个批处理都会回滚
        }
    }
}

为什么这样不好?

如果 msg.sender 是一个在接收 ETH 时会回滚的合约(无论是什么原因 - 也许它们是恶意的),整个 withdraw 函数会对每个人回滚!这造成了真正的 DoS 情况,因为合法用户因为一个失败的 地址 无法提取资金。想象一下会发生什么样的混乱!

解决方案:拉取模式

不要推送,让用户拉回他们的代币。他们从合约发起转账。这就像给他们金库的钥匙。这看起来是这样的:

// 拉取模式:更安全!
mapping(address => uint) public withdrawableBalances;

// 步骤 1:管理员标记资金为可提款,而不实际发送
function startBatchWithdrawal() public {
    address[] memory users = getUsers(); // 一个获取用户的假想函数
    for (uint i = 0; i < users.length; i++) {
        uint amount = balances[users[i]];
        if (amount > 0) {
            balances[users[i]] = 0;
            withdrawableBalances[users[i]] += amount;
        }
    }
}

// 步骤 2:每个用户分别提取自己的资金
function withdraw() public {
    uint amount = withdrawableBalances[msg.sender];
    require(amount > 0, "没有资金可提取");
    withdrawableBalances[msg.sender] = 0;
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "转账失败");
}

现在,如果某用户的提款失败,只影响他们。合约继续为其他人正常运作。这才是正确的去中心化方式!require 语句检查转账是否成功。如果不成功,该交易仅对请求用户回滚,对正在进行的操作的影响降至最低。

例子:检查清单中的例子详细描述了一个情况,在该情况中费用在用户提取之前被转移给了所有者。如果所有者的地址意外设置为零地址,或者如果所有者是一个在代币转账时会回滚的调皮合约,用户提款将失败。哎呀。 这个调试起来很麻烦!最小示例和用 Foundry 编写的 PoC 可在这里获得。

检查清单的力量:在代码审查时只需问一句“我们是否在使用拉取模式?”可能在这段 DoS 漏洞之前就会被标记出来。预防永远比治愈更好(而且更便宜)。

SOL-AM-DOSA-2: 是否强制执行最低交易金额?

翻译:你是否在防止“尘埃”交易(微不足道的、小量的)堵塞你的合约并使一切变得麻烦?

这个问题完全是关于 防止垃圾邮件,简单明了。如果你的合约允许用户以任意金额(即使是细小、微不足道的代币份额)与其互动,攻击者可以 通过无数零值或近零值交易涌入它。所有这些交易都占用了 gas,使得合法操作的成本显著增加。这种费用的增加可能会达到区块 gas 限制。如果那样发生,合法用户将无法与你的协议互动。以下是脆弱代码的样子:

// 易受尘埃攻击
struct WithdrawalRequest {
    address user;
    uint amount;
}
WithdrawalRequest[] public withdrawalRequests;

// 任何人都可以提交任何金额的提款请求(甚至 1 wei!)
function requestWithdrawal(uint amount) external {
    require(balances[msg.sender] >= amount, "余额不足");
    balances[msg.sender] -= amount;

    // 加入全球提款队列 - 没有最低金额检查!
    withdrawalRequests.push(WithdrawalRequest(msg.sender, amount));
}

function processWithdrawals() external onlyOwner {
    for (uint256 i = 0; i < withdrawalRequests.length; i++) { // 这使得攻击加剧,因为它试图一次处理所有请求
        WithdrawalRequest memory request = withdrawalRequests[i];
        request.user.transfer(request.amount);
    }
    withdrawalRequests = new WithdrawalRequest[](0);
}

解决方案:强制最低

简单的 require 语句可以在这一点上大大改善。用一个 require 语句强制设置一个阈值,就像保安检查身份证那样。我们还需要确保能够批量处理提款请求。这里是实现方式:

uint public minimumWithdrawal = 0.1 ether;

function requestWithdrawal(uint amount) external {
    require(balances[msg.sender] >= amount, "余额不足");
    require(amount >= minimumWithdrawal, "低于最低提款阈值的金额"); // 保安!

    balances[msg.sender] -= amount;
    withdrawalRequests.push(WithdrawalRequest(msg.sender, amount));
}

function processWithdrawals(uint count) external onlyOwner { // 现在可以批量处理
    for (uint256 i = 0; i < count; i++) {
        WithdrawalRequest memory request = withdrawalRequests[i];
        request.user.transfer(request.amount);
    }
    for (uint i = 0; i < withdrawalRequests.length - count; i++) {
        withdrawalRequests[i] = withdrawalRequests[i + count];
    }
    withdrawalRequests.length = withdrawalRequests.length - count;
}

例子

检查清单中的例子强调了一个合约,用户可以 请求字面上的任何数量的提款(甚至为零),无论多小。攻击者可以利用这个情况创建大量的零值提款请求。这样导致处理合法提款的成本变得非常高,让合法用户消耗大量 gas。最小示例和用 Foundry 编写的 PoC 可在这里获得。

检查清单的力量:简单的问题“是否强制执行最低交易金额?”迫使你停下并思考这个特定的攻击向量,并实施相当简单的保护措施。

SOL-AM-DOSA-3: 协议如何处理黑名单功能Token?

翻译:你是否在考虑使用可 黑名单地址 的代币(看着你,USDC 及类似代币)所引发的影响?

这是一个更加微妙的问题,并且在当今逐渐演变的受规管稳定币世界中 非常 重要。某些代币(不点名,但咳咳 USDT 咳咳 USDC 咳咳)具有黑名单功能。这意味着 中央权威可以冻结或彻底阻止特定地址使用该代币

为什么这是一个潜在的 DoS 定时炸弹?

想象一个社区质押合约,其中朋友、家人或投资伙伴将他们的代币汇聚在一个 “质押小组” 中。听起来合作高效,对吧?每个成员根据他们的贡献分配一部分奖励。

但事情危险的趣味在于:如果你的质押小组中仅有一位成员被代币合约黑名单,那么会怎么样?也许你的表弟被列入某个监管观察名单,或你的朋友的地址因为某些完全无关的交易而被标记。这对其他小组成员来说没什么大不了,是吧?错了!

当你的小组试图提取奖励时,整个交易会轰然失败!为什么?因为合约试图 在一个单个交易中为所有成员分配奖励。如果一个转账失败,整个操作就会回滚!你们小组的100 ETH综合价值的代币?完全被锁住!你的计划提款?不可能!这一切都是因为一个成员的黑名单,而这可能与你的质押活动毫无关系!

你知道更糟糕的是什么吗?通常没有办法移除黑名单中的成员或重新分配股份。小组的资金有效地被冻结,直到代币的黑名单得以更新 - 如果黑名单是出于监管原因,这种事情可能永远不会发生。

这不是一些理论担忧 - 这已经在真实合约中发生!单一故障导致多个用户同时受影响。没人要求的集体惩罚机制!可怕,不是吗?

下面是代码中的样子:

contract GroupStaking {
    IERC20 public token;

    struct StakingGroup {
        uint256 id;
        uint256 totalAmount;
        address[] members;
        uint256[] weights;
        bool exists;
    }

    // 从小组 ID 到小组数据的映射
    mapping(uint256 => StakingGroup) public stakingGroups;
    // 当前小组 ID 计数器
    uint256 public nextGroupId = 1;

    constructor(IERC20 _token) {
        token = _token;
    }

    // 创建新的质押小组
    function createStakingGroup(address[] calldata _members, uint256[] calldata _weights) external returns (uint256) {
        require(_members.length > 0, "空成员列表");
        require(_members.length == _weights.length, "成员与权重长度不匹配");

        // 验证权重总和是否为100%
        uint256 totalWeight = 0;
        for (uint256 i = 0; i < _weights.length; i++) {
            totalWeight += _weights[i];
        }
        require(totalWeight == 100, "权重必须总和为100");

        uint256 groupId = nextGroupId;
        stakingGroups[groupId] = StakingGroup({
            id: groupId,
            totalAmount: 0,
            members: _members,
            weights: _weights,
            exists: true
        });

        nextGroupId++;
        return groupId;
    }

    // 向小组质押代币
    function stakeToGroup(uint256 _groupId, uint256 _amount) external {
        require(stakingGroups[_groupId].exists, "小组不存在");
        require(token.transferFrom(msg.sender, address(this), _amount), "转账失败");

        stakingGroups[_groupId].totalAmount += _amount;
    }

    // 根据权重从小组中提取代币,分配奖励
    function withdrawFromGroup(uint256 _groupId, uint256 _amount) external {
        StakingGroup storage group = stakingGroups[_groupId];
        require(group.exists, "小组不存在");
        require(group.totalAmount >= _amount, "小组余额不足");

        // 只有小组成员可以发起提款
        bool isMember = false;
        for (uint256 i = 0; i < group.members.length; i++) {
            if (group.members[i] == msg.sender) {
                isMember = true;
                break;
            }
        }
        require(isMember, "不是小组成员");

        // 更新小组的总金额
        group.totalAmount -= _amount;

        // 根据权重向所有成员分配提取金额
        // 脆弱:如果有任何成员被黑名单,整个分配将失败
        for (uint256 i = 0; i < group.members.length; i++) {
            uint256 memberShare = (_amount * group.weights[i]) / 100;
            if (memberShare > 0) {
                token.transfer(group.members[i], memberShare);
            }
        }
    }

    // 获取小组信息
    function getGroupInfo(uint256 _groupId) external view returns (
        uint256 id,
        uint256 totalAmount,
        address[] memory members,
        uint256[] memory weights
    ) {
        StakingGroup storage group = stakingGroups[_groupId];
        require(group.exists, "小组不存在");

        return (
            group.id,
            group.totalAmount,
            group.members,
            group.weights
        );
    }
}

解决方案:考虑黑名单。取决于你的容忍水平。

不幸的是,这里没有单一的“正确”答案。每种情况都是如此具体。它真的依赖于你的协议设计、风险承受能力,甚至是与用户的法律协议。至少,要关注这种可能性并设计一个后备机制,同时确保合规性。

检查清单的力量:此检查清单项目迫使你批判性地思考 外部依赖 及其对你协议核心功能的潜在影响。它实际上是为了预期最坏的情况并提供至少一些备用方案。

最小示例和用 Foundry 编写的 PoC 可在这里获得。

总结

拒绝服务(DoS)攻击:一种试图干扰或使智能合约或其功能对合法用户不可用的攻击。这可以通过消耗过多的 gas、导致合约回滚,或利用漏洞阻止关键操作来实现。

提款模式:一种安全的智能合约设计模式,强调提款的“拉取”模型。合约不推送Token给用户,用户发起提款过程,从而减少因接收用户转账失败而造成的 DoS 脆弱性问题。

尘埃交易:这些是极低价值的交易,通常是恶意使用的,干扰网络或智能合约,使合法交易成本更高或处理受到阻碍。

代币黑名单:某些代币合约实施的特性(例如,某些 ERC-20 实现),允许特定地址被阻止转移代币。这实际上冻结了他们的资金或阻止他们与该代币互动。

结论

我们讨论了 拉取模式(拥抱用户控制)、最低交易金额(在接受之前进行检查)以及 代币黑名单 的潜在雷区(了解你的依赖)。

通过在开发过程中主动考虑这些检查清单项目,你不仅仅是在编写代码。你在创建一个 更安全、更可控且更值得信赖的生态系统。而这,朋友们,是真正值得追求的目标!信任和透明度是你最宝贵的资产。

敬请期待下一期!如果你觉得这篇文章有帮助,请随意分享给你的战友们。让我们一项一项地使 DeFi 空间成为一个更安全的地方!

  • 原文链接: cyfrin.io/blog/solodit-c...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
cyfrin
cyfrin
Securing the blockchain and its users. Industry-leading smart contract audits, tools, and education.