智能合约与传统编程的区别
智能合约编程与传统的编程在信息和资金安全方面有下面的一些不同。第一,智能合约本身可以存储几千万甚至几个亿美金的资产。第二,智能合约在链上面的部署是通过共识的,一旦部署成功就不能修改。所以即使已经发现有安全漏洞,也不能用传统的方法进行打补丁或者升级。必须在智能合约设计和编码过程把容错和异常终止逻辑写进智能合约里面。第三,公有链上的智能合约对大家都是公开的,一般没有传统的加密、审计和访问控制。第四,目前智能合约的开发还在初级阶段,编程模式和传统的SDLC(Software Development Life Cycel,,软件开发生命周期)需要进行有效的改造来适应智能合约的安全需求。
因此,仅仅抵御OWASP已知的漏洞(OWASP Top 10)是不够的,我们需要学习新的安全编程模式。同时我们必须知道安全环境的不断变化,智能合约肯定会有新的错误和安全风险,最佳安全实践肯定会被不断地更新和加强。我们预计以后会产生在智能合约安全有专业知识和经验的公司来帮助区块链项目的安全落地。图3-1是网站etherscan.io智能合约帐号和对应的以太数量的列表。可以看出这些智能合约所包含的巨额资金。因为智能合约代码是明码分布在区块链上面,黑客可以每时每刻地研究安全漏洞。一旦发现,就可能把合约里面的部分甚至所有资金偷走。
etherscan.io智能合约帐号和对应的以太数量的列表
外部调用
尽可能避免外部调用,对不可信合约的调用可能会引发几个意外的风险或错误。外部调用可能会在该合约或其所依赖的任何其他合约中执行恶意代码。因此,每个外部调用应被视为潜在的安全风险,如果可能,将其删除。当无法删除外部调用时,请使用本节其余部分中的建议来尽量减少危险。
我们来看看下面的不安全的外部函数调用的例子:
//调用外部函数amountToWithdraw完成统计
mapping (address => uint) private userBalances;
function withdrawBalance() public
{
uint amountToWithdraw = userBalances[msg.sender];
//此时,调用方的代码已经被执行,并且可以再次调用withdrawBalance
{ throw; }
userBalances[msg.sender] = 0;
}
上面的函数withdrawBalance调用外部函数amountToWithdraw,amountToWithdraw函数可能反过来调用
withdrawBalance,由于用户的余额在功能结束之前尚未设置为0,所以第二次(及第二次以后)的调用仍然会成功,并且将一次又一次地提取余额。 这就可以造成类似The DAO一样的攻击。黑客可以使用这个漏洞递归地把所有资金转走。
在给出的示例中,避免问题的最佳方法是使用send()而不是call.value()()。这将防止任何外部代码被执行。但是,如果无法删除外部调用,则防止此次攻击的最简单方法是在完成所需内部工作之前确保不调用外部函数:
mapping (address => uint) private userBalances;
function withdrawBalance() public
{
uint amountToWithdraw = userBalances[msg.sender];
userBalances[msg.sender] = 0;
//用户的余额已经被设置为0,所以未来的调用不会转走任何资金
}
外部函数调用需要解决的竞争条件
由于竞争条件可能发生在多个函数,甚至多个合约中,任何旨在防止类似The DAO重入攻击的解决方案都是不够的。相反,我们建议先完成函数内部所有工作,然后才调用外部函数。这个规则,如果仔细跟踪将允许我们避免竞争条件。但是,我们不仅要避免太早调用外部函数(在没有完成函数内部工作之前),还要避免调用“调用外部函数”的函数。例如,以下是不安全的:
// INSECURE
mapping (address => uint) private userBalances;
mapping (address => bool) private claimedBonus;
mapping (address => uint) private rewardsForA;
function withdraw(address recipient) public
{
//this function calls external function
uint amountToWithdraw = userBalances[recipient];
rewardsForA[recipient] = 0;
{ throw; } //external function
}
function getFirstWithdrawalBonus(address recipient) public
{
if (claimedBonus[recipient]) { throw; }
// Each recipient should only be able to claim the bonus once
rewardsForA[recipient] += 100;
withdraw(recipient);
// this calls external function which also calls an external function.
claimedBonus[recipient] = true;
}
即使getFirstWithdrawalBonus()不直接调用外部合约,在withdraw()中的调用也足以使其容易受到竞争条件的影响。因此,你需要对withdraw()进行处理,就好像它也是不受信任的。
mapping (address => uint) private userBalances;
mapping (address => bool) private claimedBonus;
mapping (address => uint) private rewardsForA;
function untrustedWithdraw(address recipient) public
{
uint amountToWithdraw = userBalances[recipient];
rewardsForA[recipient] = 0;
{ throw; }
}
function untrustedGetFirstWithdrawalBonus(address recipient) public
{
if (claimedBonus[recipient]) { throw; }
// Each recipient should only be able to claim the bonus once
claimedBonus[recipient] = true;
rewardsForA[recipient] += 100;
// claimedBonus has been set to true, so reentry is impossible
untrustedWithdraw(recipient);
}
在上面的例子,我们除了修复不可能重入,也把函数不可信任地调用作了标记。由于untrustedGetFirstWithdrawalBonus()调用untrustedWithdraw()。由于它调用外部合约,所以必须将untrustedGetFirstWithdrawalBonus()视为不安全的。
另一种经常用到的解决方案是使用互斥(mutex)。这允许我们“锁定”一些状态,因此只能由锁的所有者更改。一个简单的例子如下所示:
// Note:这是一个基本的例子,对互斥是特别有用的,蕴含大量的逻辑和/或共享状态
mapping (address => uint) private balances;
bool private lockBalances;
function deposit() payable public returns (bool)
{
if (!lockBalances)
{
lockBalances = true;
balances[msg.sender] += msg.value;
lockBalances = false;
return true;
} throw;
}
function withdraw(uint amount) payable public returns (bool)
{
if (!lockBalances && amount > 0 && balances[msg.sender] >= amount)
{
lockBalances = true;
{
// Normally insecure, but the mutex saves it
balances[msg.sender] -= amount;
}
lockBalances = false;
return true;
}
throw;
}
如果用户在第一次调用完成之前尝试再次调用withdraw(),则该锁将防止其产生任何影响。这可以是有效的模式,但是当你有多个合约需要合作时,它会变得棘手。以下是不安全的:
// INSECURE
contract StateHolder
{
uint private n;
address private lockHolder;
function getLock()
{
if (lockHolder != 0) { throw; }
lockHolder = msg.sender;
}
function releaseLock()
{
lockHolder = 0;
}
function set(uint newState)
{
if (msg.sender != lockHolder) { throw; }
n = newState;
}
}
攻击者可以调用getLock(),然后不要调用releaseLock()。 如果他们这样做,那么合约将永远被锁定,也不会有进一步的变化。 如果我们使用互斥来防范竞争条件我们需要充分测试防止死锁。
下一章我们将介绍外部调用错误的处理。
感谢机械工业出版社华章分社的投稿,本文来自于华章出版的著作《区块链安全技术指南》。
作者简介:
黄连金
硅谷Dynamic Fintech Group管理合伙人
吴思进
33复杂美创始人及CEO
曹锋
PCHAIN发起人,中物联区块链协会首席科学家
季宙栋
Onchain分布科技首席战略官,本体联合创始人,
马臣云
北京信任度科技CEO、信息安全专家、产品管理专家
李恩典
美国分布式商业应用公司董事与中国区总裁
徐浩铭
CyberVein数脉链项目技术负责人
翁俊杰
IBM 10余年开发及解决方案经验,批Fabric应用开发者,NEO核心开发者之一
矩阵数字经济智库由矩阵财经依托“MATRIX贝叶斯研究基金”(MATRIX与清华大学教育基金会联合成立)和MATRIX与“一带一路研究中心”的战略合作协议发起。智库将联合区块链、人工智能、金融、数字资产管理与投资领域的专家,聚焦传统产业转型和技术/商业创新,以新技术赋能实体经济,推动技术进步,引领数字经济的变革。
矩阵财经出品
领取专属 10元无门槛券
私享最新 技术干货