今天看了下ethernaut的题库,发现又多了一道题,质量感觉还行,搜了下貌似还没有write up,那就顺手写一笔吧
该挑战的代码并不长,如下
pragma solidity ^0.4.24;
import 'zeppelin-solidity/contracts/ownership/Ownable.sol';
contract AlienCodex is Ownable {
bool public contact;
bytes32[] public codex;
modifier contacted() {
assert(contact);
_;
}
function make_contact(bytes32[] _firstContactMessage) public {
assert(_firstContactMessage.length > 2**200);
contact = true;
}
function record(bytes32 _content) contacted public {
codex.push(_content);
}
function retract() contacted public {
codex.length--;
}
function revise(uint i, bytes32 _content) contacted public {
codex[i] = _content;
}
}
开头import了Ownable合约,其实就是为了引入一个owner变量,虽然不是那么有必要,但勉强也算是一个考点吧,因为solidity中的继承就是代码引用,所以这个owner变量会使用首个存储位,后面我们也会作说明
代码的逻辑也很简单,虽然从名字上看有点懵逼,貌似还是个书名
通过make_contact函数我们可以将contact变量置为true,这样相当于启动了整个合约,毕竟调用其他几个函数的前提都是contact为true,然后使用另外三个函数我们就可以增删改codex这个动态数组了,先不说后面的利用点如何,首先我们得把make_contact函数的判断给满足了,这要求我们传入一个长度大于2**200的数组,这个确实是有点大了,在这给人的感觉就很突兀,因为合约设计的比较简单,所以这里的判断的逻辑显得就不是那么好,不过作为一道题目,我们学到利用点就好
刚看到这个判断的时候我的想法是直接部署一个合约,设置一个bytes32动态数组,将其长度设为2**200+1,然后传递给make_contact函数,就像下面这样
bytes32[] public s;
function pwn(){
s.length=2**200+1;
target.make_contact(s);
}
习惯上我就直接在remix上部署了这个攻击合约,因为这两天ropsten测试链在分叉,交易经常会被打包到分叉链上,所以我先在remix的jvm虚拟机上测试了一下,结果一调用该函数过了一会页面就崩溃了,多尝试几次也是一样,然后我又在测试链试了试,交易也是无法成功进行,这让我有点苦恼,因为本地页面的崩溃所以我也不好debug
随后我对测试合约进行了修改
bytes32[] public s;
function pwn(uint n){
s.length=n;
target.make_contact(s);
}
试了试将数组长度降低到三位数,调用函数后发现了端倪,因为该函数的运算时间相比来说比较长,而且耗费的gas也比较多,随着n的增大使用的gas也不断增多,到这我差不多也明白是怎么回事了,在remix本地对交易进行处理的时候对于传递的数组参数是遵循其长度进行打包的,这样相当于length越长,发送的data也越多,所以攻击合约在调用make_contact函数时会消耗大量的gas,在测试链上跑也是一样,显然数组长度为2**200时早就炸了,所以这里我们就不能用常规的工具来发送交易
同时我们也要了解solidity中获取参数中数组变量的长度时也是从data中解码后获取的,而且这里并没有使用我们传递的数组中的值,而是只取用了长度,那么我们就可以伪装一下了
此外solidity中并没有对参数中动态数组的长度与后面跟着的内容长度作校验,所以是可以正确运行的,大概是为了省gas?
这里我直接用web3.js来发送交易,首先我们得了解一下如何构造交易的data
第一步自然是构造函数选择器,EVM中正是取data的前四个字节来选择调用合约的哪个函数,这四个字节则是取对应函数的函数签名的hash函数的前四个字节,规则如下
bytes4(keccak256("foo(uint32,bool)"))
函数签名中仅保留了函数名和函数参数的类型
下面就是要传递的参数,如果是固定大小的参数类型,那么直接将其值编码在data中即可,不过对于我们这里用到的动态数组,首先要编码的是它的偏移值,也就是它的数据部分开始的首个字节位置,要注意这里的偏移是去掉函数选择器后在data中的位置,然后解码时跟随偏移值所指示的到了开始的这个块里,其中存的就是数组的长度,接下来块里依次就是数组内容,对于我们这里要构造的data,如下所示
const util = require('ethereumjs-util');
sig=util.sha3('make_contact(bytes32[])').slice(0,4); //0x1d3d4c0b
sig="0x1d3d4c0b" //函数选择器
data1="0000000000000000000000000000000000000000000000000000000000000020";//动态数组的偏移,即数组长度存储的部分,起始于32字节
data2="1000000000000000000000000000000000000000000000000000000000000001";//数组的长度
data=sig+data1+data2;
//data="0x1d3d4c0b00000000000000000000000000000000000000000000000000000000000000201000000000000000000000000000000000000000000000000000000000000001"
如果你直接在remix部署攻击合约的话,在执行代码时对data进行编码时就会在数组长度后面跟上相应数量的块,一个就是32个字节长,可以想象在后面情况会有多可怕
然后我们准备发送该交易
var Web3 = require("web3");
var web3 = new Web3();
web3.setProvider(new Web3.providers.HttpProvider("https://ropsten.infura.io"));
web3.eth.accounts.wallet.add(your private key);
web3.eth.defaultAccount=web3.eth.accounts.wallet[0].address;
web3.eth.sendTransaction({
to: 'your challenge address',
data: data,
gas: 1000000
})
不出意外的话交易应该是会成功执行的,鉴于测试链处于分叉阶段不太稳定,没有看到交易的话不妨多试几次
此时我们来看目标合约的contact变量应该就是true了,下面就是下一个利用点了,挑战的目标是要我们取得该合约的owner,然而整个合约中并没有可修改owner的函数,那么显然需要找到漏洞点来覆盖owner所在的存储位,其实这个漏洞点在capture the ether里也出现过,所以我看着还是比较眼熟的
这里是我当时写的write up,mapping挑战
在里面我已经讲的很清楚了,因为solidity中计算存储位时使用公式为
keccak256(slot) + index
在由数组长度所在的存储位slot算得数据的首位所在存储位后,基本上后面的数据就是按照顺序排下来,所以在数组长度特别大时就会出现溢出的情况,因为存储位本身也是256位的
在这道题中很明显问题在这两个函数
function retract() contacted public {
codex.length--;
}
function revise(uint i, bytes32 _content) contacted public {
codex[i] = _content;
}
通过retract函数我们可以将codex的length下溢,然后在revise里我们就能对溢出存储位长度所需的index部分进行赋值,要注意的是直接调用revise是不行的,因为length不够长,比传入的i小的话是无法成功赋值的,所以先使用restract对length进行下溢还是很重要的
然后要计算溢出所需的index,我们就要知道数组长度所在的存储位,其实这里也有一个小考点,owner是继承过来的相当于在最前面赋值,所以应该在0号slot,这样的话按照顺序排下来contact在1号,codex就应该在2号,然而事实上这里solidity进行了存储优化,将bool类型和address类型使用的空间进行了合并,因为它们两占用空间都是小于32字节的,在启用了存储优化后,当你定义的这个变量所占的空间小于32个字节时,它所占据的这个存储位的空间可以与它后面的变量共享,当然前提是这个变量塞的下去,下面就是变量的存储情况
在slot 0中后四十位即为地址变量,而前一位即bool变量contact,此时它为1,在slot 1中存储的就是codex的长度了,此时它以及下溢了
这样的话我们要使用的slot也就是1了,这其实跟mapping那道题目一样了,省点事我就直接把那边的图拿过来用了
得到
index=35707666377435648211887908874984608119992236509074197713628505308453184860938
这样的话我们将得到的index输入revise函数i,将_content换成我们的账户地址即可
这时我们的挑战应该就完成了
做完感觉还是有些收获吧,如果合约设计的更符合逻辑一点就比较完美了