先打个生活中的比方:
你去ATM取钱,机器有个Bug:先吐出100块现金,然后才扣你账户的钱。正常人拿了钱就走,但如果你手速够快,在它扣款前又按了一次“取款”,机器可能又吐100块给你。你多按几次,账户还没扣款,就已经被你取了好几百。这就跟重入攻击差不多:ATM(智能合约)先干活(吐钱),你在它记账(扣款)前反复操作,把钱“偷”走。
重入攻击的核心就是利用“先做事、后记账”的顺序问题,在账本更新前反复“薅羊毛”。在智能合约里,“做事”通常是转账ETH,“记账”是更新余额。只要顺序不对,攻击者就能钻空子。所以写代码时,得先把账算清楚,再干别的,别给坏人留机会!
在区块链的世界里,智能合约就像个自动执行的“机器人”,规则写好了没人能改,多酷啊!但如果代码有漏洞,后果可就惨了。2016年的TheDAO事件就是个血淋淋的例子,不仅丢了几百万美元的ETH,还把以太坊社区搞分裂了。今天我们就来聊聊重入攻击,看看它咋发生的,又该咋防。
TheDAO是个去中心化自治组织,简单说就是个“众筹投资平台”。你投ETH进去,换成DAO代币,然后大家投票决定把钱投给啥项目,有点像区块链版的“投资俱乐部”。2016年,TheDAO刚上线就吸金1.5亿美金的ETH(当时占以太坊流通量的14%),一时风头无两。 可这么大一笔钱全靠智能合约管,代码安全吗?
2016年6月,一个攻击者发现了TheDAO的漏洞,用“重入攻击”几小时内偷走了360万ETH(当时值6000万美元)。问题出在splitDAO函数,这函数本是让用户退出组织、拿回ETH用的,但逻辑有毛病:先把ETH转给用户,再更新余额。攻击者抓住这个空子,反复调用splitDAO,在余额清零前把钱掏空。
TheDAO被偷后,以太坊社区炸开了锅。有人说:“区块链不是不可改吗?丢了就认了吧。”但更多人觉得不能便宜了攻击者。于是,以太坊基金会搞了个“硬分叉”,回滚交易把钱还回去。这招救回了资金,可也惹恼了一部分人,他们坚持“不改规则”,继续维护老链,成了后来的以太坊经典(ETC)。新链叫ETH,从此一分为二,重入攻击也成了安全史上绕不过去的坎。
想看原版代码?戳这里:
https://etherscan.io/address/0xbb9bc244d798123fde783fcc1c72d3bb8c189413#code
重点看splitDAO和withdrawRewardFor函数。
致命一击在这行:
if (_recipient.call.value(_amount)()) {
这行代码用call函数给_recipient转账_amount的ETH,结果触发了接收方的fallback函数。攻击者在这儿动手脚,在余额更新前反复调用splitDAO,把钱偷光。
我们用个简单例子还原一下攻击过程,看看是咋回事?
contract Bank {
mapping (address => uint) public balances;
// 存钱
function deposit() public payable {
balances[msg.sender] = msg.value;
}
// 取钱(有坑)
function withdraw() public {
if (!msg.sender.call.value(balances[msg.sender])()) { // 这行就是坑
revert();
}
balances[msg.sender] = 0; // 取完清零
}
}
问题在哪? 先转账后清零,攻击者能在清零前多取几次钱。
contract Hacker {
Bank public bank;
constructor(address _bank) public {
bank = Bank(_bank);
}
// 攻击入口
function attack() public payable {
bank.deposit.value(msg.value)(); // 先存点钱
bank.withdraw(); // 再取钱
}
// 收到钱时自动触发
function() public payable {
if (address(bank).balance >= msg.value) { // Bank还有钱就继续取
bank.withdraw();
}
}
}
Bank合约)上线,里面众筹了很多投资者的钱。Hacker合约,调用attack,先存点ETH(比如5 ETH),然后取钱。Bank的withdraw转账时触发Hacker的fallback,再调用withdraw,反复取钱,直到把Bank掏空。
从技术角度看,重入攻击的根源在于外部调用的状态管理不当。具体来说:
withdraw函数先通过call执行外部转账,再更新余额。这给了攻击者窗口期,在余额清零前反复操作。call的低级特性:call是个低级调用函数,会把大量gas(默认剩余全部gas)交给被调用方,还允许对方跑任意代码。攻击者靠这个在fallback里递归调用withdraw。Bank没限制递归调用,攻击者就能无限“插队”偷钱。
本质上,这是开发时没算准外部调用的风险,加上状态和交互顺序没搞对,才让攻击得手。知道坑在哪,我们就得填上。以下是四招实用办法:
function withdraw() public {
uint amount = balances[msg.sender];
balances[msg.sender] = 0; // 先清零
if (!msg.sender.call.value(amount)()) {
revert();
}
}
先把账记好再转账,就算攻击者重入也没钱可拿。
bool private locked;
modifier noReentrancy() {
require(!locked, "别重入了!");
locked = true;
_;
locked = false;
}
function withdraw() public noReentrancy { /* ... */ }
锁上门,攻击者想再进来也没辙。或者直接用OpenZeppelin的ReentrancyGuard,省事又安全。
transfer或send换掉callcall太“豪放”,给的gas太多,攻击者能搞乱。换成transfer或send,只给2300 gas,限制了fallback的发挥。不过得注意,gas不够时转账会失败,合约得做好失败处理。
Solidity 0.8.0之后,call失败会自动报错,不用自己写一堆检查。配合现成的安全库,防重入更省心。
重入攻击说白了,就是“先做事、后记账”惹的祸。TheDAO事件给我们敲了个警钟:智能合约再牛,逻辑有漏洞也能翻车。尤其在“真金白银”的场景里,多留个心眼总没错!
基于全文检索与主题相似度
说在前面 生活中,我们习惯了依赖时间来安排一切。闹钟提醒我们起床,高铁准点发车,考试到点交卷,股票交易精确到毫秒撮合…… 在 Web2 世界里,软件系统同样靠精准的时间戳来自动执行任务,比如定时扣款、会员到期、订单结算,这一切都建立在可信的时间基础上。但到了 Web3 世界里,时间居然能被“调表”!
0转账钓鱼攻击是个啥? 0转账钓鱼攻击(Zero Transfer Phishing Attack)是一种简单却阴险的骗术,虽然技术含量不高,但在币圈屡屡得手,甚至酿成过惊天大案(见下文)。 每次听说这种攻击得逞,连我都忍不住暗想:这黑客咋不是我呢? 先打个生活中的比方: 假设你和好友张三经常通过银
说在前面 玩过币的人都知道,ERC20是以太坊上一个常见的代币标准(支持钱包交易交互),里面有个类似开支票的功能——approve授权和transferFrom转账兑现。 如同现实生活中你给人开了一张支票,写上金额,对方拿着支票去银行就能把钱取走。你账户里的钱少了,他的账户里钱多了。 在智能合约里,