随机错觉(Entropy lllusion)

随机错觉,也称为熵错觉,指的是开发者错误地认为在区块链上可以轻易地获得安全、不可预测的随机数

简单来说,这是一种“我以为它是随机的,但实际上并非如此”的认知错误。

示例

一个私有视图函数,用于根据一定的随机性条件判断是否执行空投。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function airdrop() private view returns(bool)
{
uint256 seed = uint256(keccak256(abi.encodePacked(
(block.timestamp).add
(block.difficulty).add
((uint256(keccak256(abi.encodePacked(block.coinbase)))) / (now)).add
(block.gaslimit).add
((uint256(keccak256(abi.encodePacked(msg.sender)))) / (now)).add
(block.number)
)));
if((seed - ((seed / 1000) * 1000)) < airDropTracker_)
return(true);
else
return(false);
}

一个修饰器,用于确保调用者是外部账户(EOA)而非合约。

1
2
3
4
5
6
7
8
modifier isHuman() {
address _addr = msg.sender;
uint256 _codeLength;

assembly { _codeLength := extcodesize(_addr) }
require(_codeLength == 0, "sorry humans only");
_;
}

airdrop() 函数中的随机错觉

这个函数试图通过组合多个区块链变量来生成一个“随机”的种子 seed

1
2
3
4
5
6
7
8
uint256 seed = uint256(keccak256(abi.encodePacked(
(block.timestamp).add
(block.difficulty).add
((uint256(keccak256(abi.encodePacked(block.coinbase)))) / (now)).add
(block.gaslimit).add
((uint256(keccak256(abi.encodePacked(msg.sender)))) / (now)).add
(block.number)
)));

它使用的变量(如 block.timestamp, block.difficulty, block.coinbase 等)对于外部观察者来说可能是难以预测的,但对于区块链网络内的参与者,尤其是矿工/验证者来说,情况就完全不同了。

攻击方式:

  1. 矿工/验证者操纵
    • 矿工在打包交易和生成新区块时,对这些变量拥有相当大的控制权。
    • 他们可以稍微调整 timestamp,选择性地包含交易,甚至在一定程度上影响 gasLimitdifficulty
    • 如果一个空投奖励非常丰厚,矿工完全可以只选择将那些能让他们自己或他们关联的地址中奖的交易打包进区块。对他们来说,这些随机源是可预知或可轻微影响的。
  2. 普通用户预测
    • 即使不是矿工,一个用户也可以通过在同一笔交易中部署一个攻击合约来“预计算”这些值。
    • 因为当合约运行时,block.numbertimestamp 等已经是确定的值。攻击合约可以在同一个交易里调用你的 airdrop 函数,先计算 seed 的值,如果发现对自己不利,就直接回滚交易(revert),只保留那些中奖的交易。这被称为 “提前计算攻击”“试探-重放攻击”

结论与安全建议

核心要点: 任何放在区块链上、在交易执行时可被读取的变量,都不应被视为安全的随机数源。这包括区块哈希、时间戳、难度值、Gas限制等。

如何避免随机错觉?

要获得更安全的随机数,通常需要结合链下信息:

  1. 预言机:使用像 Chainlink VRF 这样的专业服务,它们提供可验证的随机数,专门为解决这个问题而设计。
  2. 提交-揭示方案:引入一个延迟,让用户先提交一个他们自己都不知道的承诺(如哈希值),然后在未来某个时刻再揭示秘密。这样可以防止最后一分钟的操纵。
  3. 多方熵混合:结合多个互不信任的参与方提供的随机源。

外部合约引用漏洞

外部合约引用漏洞指的是开发者错误地认为合约引用的库或依赖合约是可信且不可篡改的

简单来说,这是一种”我以为我引用的合约永远是我最初部署的那个合约”的认知错误。实际上,通过某些设计模式,合约引用的库地址可以被恶意替换。

示例

一个加密合约,引用了外部的 ROT13 加密库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import "Rot13Encryption.sol";

// encrypt your top secret info
contract EncryptionContract {
// library for encryption
Rot13Encryption encryptionLibrary;

// constructor – initialise the library
constructor(Rot13Encryption _encryptionLibrary) {
encryptionLibrary = _encryptionLibrary;
}

function changedLibrary(Rot13Encryption _encryptionLibrary) {
encryptionLibrary = _encryptionLibrary;
}

constructor() {
encryptionLibrary = new Rot13Encryption();
}
}

EncryptionContract 合约中的外部合约引用漏洞

这个合约看似安全地引用了一个加密库,但实际上存在严重的设计缺陷:

1
2
3
function changedLibrary(Rot13Encryption _encryptionLibrary) {
encryptionLibrary = _encryptionLibrary;
}

这个函数允许任何人更改合约引用的加密库地址,这是一个致命的安全漏洞。

攻击方式:

  1. 库合约替换攻击

    • 攻击者可以部署一个恶意的”加密库”,该库具有相同的接口但包含恶意代码。
    • 通过调用 changedLibrary() 函数,攻击者将合约的 encryptionLibrary 指向他们控制的恶意合约。
    • 此后,所有通过 encryptionLibrary 进行的加密操作实际上都会执行攻击者的恶意代码。
  2. 权限缺失漏洞

    • changedLibrary() 函数没有任何权限检查(如 onlyOwner),意味着任何人都可以调用它。
    • 即使有权限检查,如果权限管理不当,仍然可能被未授权方调用。
  3. 数据篡改与窃取

    • 恶意库可以:
      • 返回伪造的加密结果
      • 记录并泄露用户的敏感数据
      • 实施重入攻击
      • 直接窃取合约资金

结论与安全建议

核心要点: 任何外部合约引用都应该被视为潜在的安全风险,特别是当这些引用可以被动态更改时。

如何避免外部合约引用漏洞?

  1. immutable 关键字:对于重要的库合约引用,使用 immutable 关键字在构造函数中初始化,防止后续修改。

    1
    2
    3
    4
    5
    Rot13Encryption immutable public encryptionLibrary;

    constructor(Rot13Encryption _encryptionLibrary) {
    encryptionLibrary = _encryptionLibrary;
    }
  2. 严格的权限控制:如果必须允许更改库引用,应该实施严格的权限控制:

    1
    2
    3
    function changedLibrary(Rot13Encryption _encryptionLibrary) external onlyOwner {
    encryptionLibrary = _encryptionLibrary;
    }
  3. 使用可信的、经过审计的库:优先使用广泛认可、经过安全审计的库合约。

  4. 库合约白名单:如果业务需要更换库,可以实施白名单机制,只允许切换到预定义的可信合约地址。

  5. 时间锁与多签:对于关键的系统组件变更,考虑引入时间锁和多签机制,给用户提供反应时间。

未检查的返回值

未检查的返回值指的是开发者忽略了底层调用(如 sendcalldelegatecall 等)的返回值,错误地假设调用总是成功

简单来说,这是一种”我以为转账总是会成功,所以不需要检查返回值”的认知错误。实际上,底层调用可能因各种原因失败,但合约逻辑却继续执行,导致状态不一致。

示例

一个彩票合约,包含向赢家发送奖金和提取剩余资金的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
contract Lotto {
bool public payedOut = false;
address public winner;
uint public winAmount;

// ... extra functionality here

function sendToWinner() public {
require(!payedOut);
winner.send(winAmount);
payedOut = true;
}

function withdrawLeftOver() public {
require(payedOut);
msg.sender.send(this.balance);
}
}

Lotto 合约中的未检查返回值漏洞

这个合约在关键的资金转账操作中没有检查返回值,存在严重的设计缺陷:

1
2
3
4
5
function sendToWinner() public {
require(!payedOut);
winner.send(winAmount); // 未检查返回值!
payedOut = true; // 无论转账是否成功都会执行
}

攻击方式:

  1. 接收合约拒绝接收

    • 如果 winner 是一个合约地址,且其 receive()fallback() 函数执行失败或主动回滚
    • winner.send(winAmount) 会返回 false 而不是回滚整个交易
    • payedOut = true 仍然会被执行,导致赢家没收到奖金但合约状态标记为已支付
  2. Gas不足导致失败

    • send() 函数只提供2300 Gas,如果接收方需要更多Gas执行逻辑,调用会失败
    • 返回值是 false 但状态仍然被修改
  3. 后续功能被锁定

    • 由于 payedOut 被错误地设置为 true,赢家无法再次调用 sendToWinner()
    • 同时 withdrawLeftOver() 可以被任意调用,导致资金被错误提取

漏洞影响:

  • 资金锁定:赢家无法获得应得的奖金
  • 状态不一致:合约状态与实际资金情况不匹配
  • 资金被盗:剩余资金可能被未授权方提取

结论与安全建议

核心要点: 所有底层调用(sendcalldelegatecallcallcodestaticcall)的返回值都必须检查,因为这些调用失败时不会自动回滚。

如何避免未检查返回值漏洞?

  1. 使用 transfer()(仅适用于EOA)

    1
    winner.transfer(winAmount); // 失败时自动回滚
  2. 检查 send() 返回值

    1
    2
    3
    4
    5
    function sendToWinner() public {
    require(!payedOut);
    require(winner.send(winAmount), "Transfer failed");
    payedOut = true;
    }
  3. 使用 call() 并检查返回值(推荐方式):

    1
    2
    3
    4
    5
    6
    function sendToWinner() public {
    require(!payedOut);
    (bool success, ) = winner.call{value: winAmount}("");
    require(success, "Transfer failed");
    payedOut = true;
    }
  4. 遵循检查-效果-交互模式

    1
    2
    3
    4
    5
    6
    function sendToWinner() public {
    require(!payedOut);
    payedOut = true; // 先更新状态
    (bool success, ) = winner.call{value: winAmount}("");
    require(success, "Transfer failed");
    }
  5. 使用现代Solidity的最佳实践

    • 避免使用已弃用的 send()
    • 使用 call() 进行ETH转账
    • 始终检查底层调用的返回值
    • 考虑使用OpenZeppelin的Address库

注意: 在Solidity 0.6.0之后,send()transfer() 都只提供2300 Gas,对于合约地址可能不够用,因此使用 call() 并适当处理重入风险是现代推荐的做法。

竞争条件/预先交易

竞争条件/预先交易指的是攻击者通过观察待处理交易池,以更高Gas费抢先执行相同操作,从而获利的行为

简单来说,这是一种”我看到你要做什么,所以我抢在你前面做同样的事情”的攻击方式。在公开的区块链交易池中,所有待处理交易都是可见的,这为预先交易创造了条件。

示例

一个哈希破解奖励合约,提供高额赏金给第一个找到特定哈希原像的人:

1
2
3
4
5
6
7
8
9
10
11
contract FindThisHash {
bytes32 constant public hash = 0xb5b5b97fafd9855eee9b41f74dfb6c38f5951141f9a3ecd7f44d5479b630ee0a;

constructor() public payable {} // load with ether

function solve(string solution) public {
// If you can find the pre image of the hash, receive 1000 ether
require(hash == sha3(solution));
msg.sender.transfer(1000 ether);
}
}

FindThisHash 合约中的竞争条件漏洞

这个合约看似公平地奖励第一个找到哈希解的用户,但实际上存在严重的预先交易风险:

1
2
3
4
function solve(string solution) public {
require(hash == sha3(solution));
msg.sender.transfer(1000 ether);
}

这个函数没有任何防止预先交易的机制,使得诚实解题者的努力可能被恶意攻击者窃取。

攻击方式:

  1. 交易池监听

    • 攻击者运行节点监听以太坊交易池(mempool)
    • 当发现有用户提交正确的 solve() 交易时,攻击者立即复制该交易的 solution 参数
  2. Gas价格竞争

    • 攻击者以更高的Gas价格提交相同的 solve() 交易
    • 由于矿工会优先打包Gas价格更高的交易,攻击者的交易会先被确认
    • 结果:攻击者抢走1000以太坊奖励,而原始解题者损失Gas费且得不到奖励
  3. 机器人自动化攻击

    • 攻击者使用自动化机器人7x24小时监控交易池
    • 一旦检测到目标合约的调用,立即进行预先交易
    • 这种攻击在DeFi领域尤其常见,如套利机会、NFT铸造等

结论与安全建议

核心要点: 在公开的区块链环境中,任何有价值的操作都可能被预先交易,特别是当操作涉及经济激励时。

如何避免竞争条件/预先交易漏洞?

  1. 提交-揭示方案

    1
    2
    3
    4
    5
    // 第一阶段:提交承诺(哈希值)
    function commit(bytes32 commitment) public;

    // 第二阶段:揭示答案
    function reveal(string solution, bytes32 salt) public;

    用户先提交 keccak256(solution, salt, msg.sender),然后在后续交易中揭示答案。

  2. 使用限价单和滑点保护:在DeFi交易中设置最大滑点,防止被三明治攻击。

  3. 批处理操作:将多个操作打包成一个交易,减少单次操作的价值密度。

  4. 隐私交易服务:使用如Flashbots、Tornado Cash等服务隐藏交易意图。

  5. 权限化访问:对于某些场景,可以采用白名单机制,但这会牺牲去中心化特性。

  6. 时间加权平均:使用时间加权的价格计算,而不是瞬时价格,减少抢跑套利的空间。

影响范围: 预先交易不仅影响赏金合约,还广泛影响DeFi套利、NFT铸造、ICO参与、治理投票等几乎所有涉及经济激励的区块链应用。

问题总结:

  1. “外部合约引用”中,引用线程部署是什么?多签合约是什么?

A. 引用线程部署

这里的“引用线程部署”很可能是一个笔误或翻译问题,正确的理解应该是时间锁

时间锁 是一种安全机制,指在对智能合约进行关键操作(如升级库合约、修改关键参数)时,强制加入一个等待期

工作原理:

  1. 当管理员发起一个变更请求(比如调用 changeLibrary 函数)时,这个变更不会立即生效
  2. 该请求会被记录,并开始一个预设的倒计时(例如 2 天、7 天)。
  3. 在等待期内,所有用户都可以看到这个即将发生的变更。
  4. 等待期结束后,管理员(或任何人)才能执行一个最终的交易,使变更正式生效。

为什么它能提高安全性?

  • 给用户反应时间:如果这是一个恶意的或有问题的变更,用户可以在等待期内看到风险,并及时撤出他们的资金。
  • 防止即时作恶:即使攻击者获取了管理员权限,他也不能立即盗取资金或破坏系统,因为等待期给了社区采取应对措施的机会。

简单代码示意:

solidity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
contract Timelock {
address public admin;
address public newLibrary;
uint public constant DELAY = 2 days; // 2天等待期
uint public changeInitiatedTime;

function proposeNewLibrary(address _newLibrary) public {
require(msg.sender == admin);
newLibrary = _newLibrary;
changeInitiatedTime = block.timestamp; // 开始计时
}

function executeChange() public {
require(block.timestamp >= changeInitiatedTime + DELAY, "Timelock not expired");
// ... 实际执行库合约更换的代码 ...
}
}

B. 多签合约

多签合约 是一种需要多个私钥授权才能执行交易的智能合约。

工作原理:

  • 它由一组预定义的地址(例如 3 个或 5 个)管理。
  • 要执行任何关键操作(如更换引用的库合约),必须有一定数量的管理员(例如 3 个中的 2 个,即 2/3)对同一笔交易进行签名确认。
  • 只有达到预设的阈值(如 2/3),交易才会被执行。

为什么它能提高安全性?

  • 分散权力:避免了单点故障。一个管理员私钥被盗或一个人变坏,不足以操控整个合约。
  • 集体决策:要求多个可信方达成共识,大大降低了恶意操作或误操作的风险。

如何结合使用?
最佳安全实践是将时间锁和多签结合

  1. 合约的管理员权限被赋予一个多签合约
  2. 任何对库合约的更改,都需要多签合约中的多数管理员批准。
  3. 批准后,该更改进入时间锁的等待期。
  4. 等待期结束后,才能最终生效。

这种“多签 + 时间锁”的模式是当今DeFi协议和DAO治理中保护核心合约安全的黄金标准。