尝试构建离线的区块链靶场
🎯
尝试构建离线的区块链靶场
之前写了一篇文章《使用defi_poc进行区块链攻击事件仿真复现实验》,介绍了怎么使用defi_poc这个靶场项目来本地复现攻击事件,文章的末尾提到了依赖第三方的问题,所以我尝试打包一个有限大小且离线不依赖第三方的区块链靶场,做了个小demo,不过也存在一些问题,算是对这件事的一些探索。
 

0x1 了解ganache

在之前的文章中有了解到,ganache可以用来模拟出一个本地测试网,而无需真实的区块链节点,并且还支持通过web3 provider rpc对链上主网或其他网络进行本地分叉(fork),对截至特定区块的链上真实状态进行快照。
功能很强大,不过首先我们搞清楚其中的原理是什么。
 
ganache构建本地分叉测试网的命令如下:
ganache-cli --fork web3-http-provider-rpc-endpoint@block-number
通过命令可以得知,在进行分叉的时候,只需要依赖一个以太坊存档节点(或其他evm链)的rpc接口即可指定区块(block-number)进行分叉,那么我们抓取ganacherpc接口的通信内容,就可以知道ganache fork的大概流程,省去了读代码的麻烦。
获取通信内容的方式有两种:
  1. 使用类似proxychains等代理工具配合抓包工具
  1. 搭建中转rpc服务,ganache连接中转rpc拦截请求响应包
 
我这里用的是第二种方式,中转服务使用flask搭建:
import json
import requests
from flask import Flask, request,  jsonify

app = Flask(__name__)
HTTP_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', 'PATCH']
ARCHIVE_NODE_RPC_URL = "https://speedy-nodes-nyc.moralis.io/xxxxxxxxxxxxxxxxxxxxxxxx/eth/mainnet/archive"


@app.route("/", methods=HTTP_METHODS)
def transit():
    print(request.get_data())
    print("=======================REQUEST============================")

    resp = requests.request(
        method=request.method,
        url=ARCHIVE_NODE_RPC_URL,
        headers={key: value for (key, value) in request.headers if key != 'Host'},
        data=request.get_data(),
        cookies=request.cookies,
        allow_redirects=False)
    resp_json = json.loads(resp.content.decode('utf8'))
    print(resp_json)
    print("=======================RESPONSE============================")

    return jsonify(resp_json)


if __name__ == '__main__':
    app.run()
运行起来后的默认URL是http://127.0.0.1:5000/,将ganache连接到该URL启动
ganache-cli --fork http://127.0.0.1:5000@13125190
notion image
可以看到,通过RPC接口获取了网络、区块、交易数等信息,仅此而已,然后我们来抓一下运行defi_poc项目中攻击脚本的时候的通信内容
notion image
可以看到图中有获取合约字节码地址余额交易数存储槽等信息,而且基本每个请求里都有0xc84646参数,这个数值是指定区块号1312519016进制,代表从rpc接口获取截至指定区块的信息。
 
ok,通过以上信息已经大概可以猜一下ganache是如何进行fork
notion image
在脚本执行的过程中,需要一些链上数据,如果本地没有,那么ganache会去节点rpc接口中拉取需要的数据,例如合约字节码、存储槽、余额等,然后如果数据是合约需要,则将数据放到本地的EVM中去处理,处理结果返回给脚本。
总而言之,就是需要什么数据,本地没有就去provider里去取,然后放到本地处理、执行,最后给出结果。
 
 

0x2 数据持久化

了解ganache的运行机制后,可以很容易想到一个解决方案:将需要的数据存起来。
我尝试用ganache自有的功能—db参数,来做到这一点,加了db参数后,确实会将从rpc取的数据在本地持久化
notion image
但是在去掉rpc依赖之后启动ganache,再运行脚本会报错
notion image
原因未知,可能缓存的数据不完整,或者别的一些什么原因,这种情况表示ganache自身的功能已经不满足需求,需要进行修改,对它进行二次开发。
二开ganache也不是不可以,只是比较麻烦,而且有点侵入式,我这里使用了另外一种比较取巧的办法。
 

0x3 fake provider

在之前抓包时,我使用的是搭建中转rpc服务来截取请求响应包,那其实稍微改造一下,做一个假的provider,将请求和响应存下来,下次请求相同的数据的时候,直接返回,而不用从在线存档节点里取,就可以简单达到目的了。
import json

import requests
from flask import Flask, request, jsonify

app = Flask(__name__)
HTTP_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', 'PATCH']
RESPONSE_CACHE = {}
ARCHIVE_NODE_RPC_URL = "https://speedy-nodes-nyc.moralis.io/xxxxxxxxxxxxxxxxxxxxxxxx/eth/mainnet/archive"


@app.route("/", methods=HTTP_METHODS)
def transit_cache():
    print(request.get_data())
    print("=======================REQUEST============================")

    req_json = json.loads(request.get_data().decode('utf8'))
    if req_json['method'] not in RESPONSE_CACHE.keys():
        RESPONSE_CACHE[req_json['method']] = {}
        try:
            for line in open(req_json['method'] + ".txt", 'r').read().splitlines():
                params, result = list(json.loads(line).items())[0]
                RESPONSE_CACHE[req_json['method']][params] = result

        except FileNotFoundError:
            open(req_json['method'] + ".txt", 'w')

    if str(req_json['params']) in RESPONSE_CACHE[req_json['method']]:
        resp_json_result = RESPONSE_CACHE[req_json['method']][str(req_json['params'])]
        resp_json = {"jsonrpc": "2.0", "id": req_json['id'], "result": resp_json_result}

        print(resp_json)
        print("=======================CACHE_RESPONSE============================")
        return jsonify(resp_json)

    resp = requests.request(
        method=request.method,
        url=ARCHIVE_NODE_RPC_URL,
        headers={key: value for (key, value) in request.headers if key != 'Host'},
        data=request.get_data(),
        cookies=request.cookies,
        allow_redirects=False)

    resp_json = json.loads(resp.content.decode('utf8'))
    print(resp_json)
    print("=======================RESPONSE============================")

    RESPONSE_CACHE[req_json['method']][str(req_json['params'])] = resp_json['result']
    open(req_json['method'] + ".txt", 'a').write(json.dumps({str(req_json['params']): resp_json['result']}) + '\n')

    return jsonify(resp_json)


if __name__ == '__main__':
    app.run()
简单地把请求method参数和param参数与响应result的对应关系以文本方式存了下来,遇到缓存里有的methodparam,就直接返回缓存里的数据。
 
将这个web服务跑起来,然后ganache连接过来
ganache-cli --fork http://127.0.0.1:5000@13125190 -s test
💡
这里加-s test是因为ganache默认会使用随机种子,每次启动ganache生成的账户地址都不一样,加了-s参数后可以将种子固定下来。如果每次地址不一样,那有些与账户地址相关联的数据,就没了缓存的意义,每次地址都不一样,那就得每次都从链上取。
 
再跑一遍PoC脚本,脚本需要的链上数据就会被存下来。
notion image
可以看到,数据被存下来了。脚本可能跑着跑着会报错(超时),没有关系,多跑几遍,直到能完整跑完脚本即可,这是因为python效率低下,没来得及给ganache返回结果,超过了ganache的预定时间,但是只要请求过的数据,都会被缓存下来,不用从在线节点去取。
 
当需要的数据都有之后,就可以不依赖在线节点了。
notion image
可以看到,只是个demo虽然很low,但是确实实现了不依赖在线节点构建靶场以及运行PoC脚本。
 

0x4 不足

这种办法构建离线靶场的好处就是占用空间小,而且灵活性高,比如我可以修改eth_getCode.txt中的数据,实现合约字节码篡改,在其中加入一些打印便于调试。
但存在一个问题:只存脚本运行刚好需要的数据。
 
比如上面章节提到的问题,换个账户地址就不行了,缺少缓存数据了,还比如别人复现的时候,PoC脚本改动太多,调了别的数据,那又缺少缓存数据了。
 
存全量数据太大,存仅需数据太少,我能想到的折中的办法就是,将常用的DeFi的一些合约数据、状态数据存下来,这样不会太少,也不是很多,这个想法留到以后实现。