免责声明:本文不构成任何投资建议,文中所涉及的技术及相关安全问题仅代表笔者观点,不具有任何指导和买卖意见。市场有风险,投资需谨慎!识别出的潜在安全问题,并不代表该合约在实际环境中就确实存在安全问题,识别出的风险点仅作为安全建议进行参考。
截图如下所示,通读说明可以提取如下关键字样:
(1)普通款藏品已经合成即“自动销毁”,隐藏款合成后,将仍会保存;
(2)合成是自动完成的:也就是说可以在持有者无感的情况下,自动持有者的普通藏品销毁,保留隐藏款,并向成功合成的用户 transfer 一个合成后的数字藏品。
考虑到,在不掌握私钥的情况下,除私钥持有人外,任何区块链账户都不具备直接销毁用户资产的能力。因此,如果需要具备自动合成及销毁普通款藏品的能力,需要开发者在数字藏品的合约代码处添加相关逻辑:
(1)记录钱包内普通款藏品数据;
(2)对钱包内隐藏款藏品进行记录;
(3)调用 NFT 合约中提供的 burn 方法,将普通款藏品销毁;
(4)调用 NFT 合约中提供的 mint 方法,将 “合成” 的藏品发行,并转移至合成成功的用户钱包处。
当上述逻辑执行完成后,用户钱包内的资产将变为合成藏品(从零地址mint 出来并转移至用户钱包),而普通款藏品将消失(由于 burn 至零地址),隐藏款藏品保留(说明代码逻辑不会对隐藏款藏品调用 burn 方法)。这样,开发者就可以在不需要账户私钥的情况下,完成这一操作。
从安全的角度来看,这一逻辑可以理解为是一种安全可控的后门:在特定情况下触发,执行敏感操作(调用 burn 方法销毁一部分资产,调用 mint 方法增发合成后的数字藏品),在不滥用和误用的前提下,用户资产是安全的。Code is Law,只要用户账户里存在15个或以上的普通藏品及2个或以上的隐藏款藏品,代码即默认认为用户愿意参与销毁15个普通藏品,消耗2个隐藏款藏品,并以此兑换一个合成后的数字资产的合成活动。
区块链操作是不可篡改的,且所有的链上交互都是公开可查的,因此我们选取了一笔“合成”交易,进行行为分析。
合成操作对应的交易数据:
调用智能合约的输入数据经解码为 JSON 格式数据处理后如下所示:
需要读者提前知晓的知识:(1)普通款数字藏品为图片格式;(2)隐藏款数字藏品为视频格式;(3)Conflux 网络有一套自有的地址转义规则,具体可参考如下链接:
https://www.confluxscan.net/address-converter
{
"name": "chill__exchange",
"fullName": "chill__exchange((address,uint256[],uint256[])[] infos, uint256 numImagesForReward, uint256 numVideosForReward)",
"type": "chill__exchange((address,uint256[],uint256[])[],uint256,uint256)",
"signature": "0xd8722bd1",
"array": [
[
[
"0x1fbe2923d9e514d20b049cfb02e7f0c7e6ea3e81",
[
"456",
"733",
"2194",
"2950",
"3900",
"4048",
"4273",
"4317",
"4784",
"5201",
"5231",
"5436",
"5475",
"6162",
"6389",
"6728",
"6833"
],
[
"7325",
"7898"
]
],
[
"0x18d5eaf71aa697583c0e64fe49f2c8a29cd161f6",
[
"474",
"799",
"1126",
"1583",
"2300",
"2478",
"3292",
"3342",
"4013",
"4016",
"5003",
"5177",
"5257",
"5813",
"6016"
],
[
"7298",
"7305"
]
],
[
"0x18b1edd5ae736ff9338f09d456499bef420dff04",
[
"478",
"569",
"1217",
"1757",
"2411",
"2777",
"3291",
"4731",
"6017",
"6095",
"6413",
"6422",
"6805",
"7086",
"7190"
],
[
"7237",
"7495"
]
]
],
"15",
"2"
],
"object": {
"infos": [
[
"0x1fbe2923d9e514d20b049cfb02e7f0c7e6ea3e81",
[
"456",
"733",
"2194",
"2950",
"3900",
"4048",
"4273",
"4317",
"4784",
"5201",
"5231",
"5436",
"5475",
"6162",
"6389",
"6728",
"6833"
],
[
"7325",
"7898"
]
],
[
"0x18d5eaf71aa697583c0e64fe49f2c8a29cd161f6",
[
"474",
"799",
"1126",
"1583",
"2300",
"2478",
"3292",
"3342",
"4013",
"4016",
"5003",
"5177",
"5257",
"5813",
"6016"
],
[
"7298",
"7305"
]
],
[
"0x18b1edd5ae736ff9338f09d456499bef420dff04",
[
"478",
"569",
"1217",
"1757",
"2411",
"2777",
"3291",
"4731",
"6017",
"6095",
"6413",
"6422",
"6805",
"7086",
"7190"
],
[
"7237",
"7495"
]
]
],
"numImagesForReward": "15",
"numVideosForReward": "2"
}
}
不难发现,cfx:aatgvya99cctj5kx0agh4b18czbav2m80adsrny7ah 账户通过发送交易调用了智能合约提供的 chill__exchange 方法。该方法对应的方法 ID 为 0xd8722bd1 。调用该方法需要指定参数列表:(address,uint256[],uint256[])[] infos, uint256 numImagesForReward, uint256 numVideosForReward),参数列表中包含 address 类型变量及两个 uint256 类型变量组成的二维数组 infos, uint256 类型变量 numImagesForReward 以及 uint256 类型变量 numVideosForReward。
通过后续阅读代码:能够理解传入的参数内容,以 0x 开头的账户,即为待进行合成操作的地址:0x18b1edd5ae736ff9338f09d456499bef420dff04。478,569,1217,...,7190 为普通款数字藏品对应的 ID 编号。7237,7495 为隐藏款数字藏品对应的 ID 编号。numImagesForReward 参数 15 则代表合成需要的普通款数字藏品数量为15,numVideosForReward 参数为 2 则代表合成需要的隐藏款数字藏品数量为2。(很重要,与最后合成时的 while 循环条件有关)
0x18b1edd5ae736ff9338f09d456499bef420dff04 对应的 Conflux 网络地址为
cfx:aapnd5szz33098kxv6e7jzwkxt1yedt9auc0hfr4nk
在第三部分,我们已经找到了智能合约的调用者及智能合约信息,可以直接对智能合约代码进行分析。经过区块浏览器查找,发现智能合约使用了OpenZeppelin's Unstructured Storage 代理模式,实际的调用合约为:
https://www.confluxscan.net/address/cfx:acakr6tw7tmd8w4vwsbswh42xyv31vjy1ubm802u1f
页面显示信息如下:
合约源码以进行验证,示意图如下:
合成关键代码如下:
modifier onlyMinter() {
require(hasRole(MINTER_ROLE, msg.sender), "DIO: Unauthorized");
_;
}
function chill__start(uint256 tokenId) public onlyMinter {
chill__inProgress = true;
chill__nextRewardId = tokenId;
}
function chill__end() public onlyMinter {
chill__inProgress = false;
}
function chill__exchange(
ExchangeInfo calldata info,
uint256 numImagesForReward,
uint256 numVideosForReward
) public onlyMinter {
require(chill__inProgress, "Not started yet");
// prepare valid images and videos
uint256[] memory images = new uint256[](info.images.length);
uint256[] memory videos = new uint256[](info.videos.length);
uint256 numImages = 0;
uint256 numVideos = 0;
for (uint256 ii = 0; ii < info.images.length; ++ii) {
uint256 tokenId = info.images[ii];
if (ownerOf(tokenId) == info.user) {
images[numImages++] = tokenId;
}
}
for (uint256 ii = 0; ii < info.videos.length; ++ii) {
uint256 tokenId = info.videos[ii];
if (ownerOf(tokenId) == info.user && !chill__videoUsed[tokenId]) {
videos[numVideos++] = tokenId;
}
}
// process
uint256 imageId = 0;
uint256 videoId = 0;
while ((numImages - imageId) >= numImagesForReward && (numVideos - videoId) >= numVideosForReward) {
// burn images
for (uint256 ii = 0; ii < numImagesForReward; ++ii) {
uint256 tokenId = images[imageId++];
_burn(tokenId);
}
// skip videos
for (uint256 ii = 0; ii < numVideosForReward; ++ii) {
uint256 tokenId = videos[videoId++];
chill__videoUsed[tokenId] = true;
}
// send out reward
emit ChillExchange(info.user, chill__nextRewardId);
this.transferFrom(address(this), info.user, chill__nextRewardId++);
}
}
一段一段来,谁也跑不了,首先是 modifier 修饰符,用于修饰合约函数,确保调用合约方法的账户必须具备 MINTER_ROLE 角色。
function chill__start(uint256 tokenId) public onlyMinter {
chill__inProgress = true;
chill__nextRewardId = tokenId;
}
function chill__end() public onlyMinter {
chill__inProgress = false;
}
上述代码不难看出,调用 chill__start 方法,需要交易发起人具备 MINTER_ROLE 角色。调用 chill__end 方法,同样需要交易发起人具备 MINTER_ROLE 角色。chill__start 方法会将chill__inProgress 状态变量设置为 true,状态位设为 true ,说明要开始进行合成操作了。当合成活动结束后 minter 还需要调用 chill__end 方法。指定 chill__nextRewardId 为调用者传入的参数 tokenId。
minter 调用 chill_start 方法交易详情如下所示:
https://www.confluxscan.net/transaction/0xeed45d94a38119db44cd4775203431c962a90bd26ca8a0fd7ff65677ca0288e7
经检查,minter 地址为 cfx:aatgvya99cctj5kx0agh4b18czbav2m80adsrny7ah,调用数据如下图所示,发现调用时传入的 tokenId 为 8002,大胆推测第一个合成成功的用户获得的 NFT 编号为 8002:
在合成前,智能合约需要处理用户账户中存储的数字藏品,关键代码如下:
require(chill__inProgress, "Not started yet");
// prepare valid images and videos
uint256[] memory images = new uint256[](info.images.length);
uint256[] memory videos = new uint256[](info.videos.length);
uint256 numImages = 0;
uint256 numVideos = 0;
for (uint256 ii = 0; ii < info.images.length; ++ii) {
uint256 tokenId = info.images[ii];
if (ownerOf(tokenId) == info.user) {
images[numImages++] = tokenId;
}
}
for (uint256 ii = 0; ii < info.videos.length; ++ii) {
uint256 tokenId = info.videos[ii];
if (ownerOf(tokenId) == info.user && !chill__videoUsed[tokenId]) {
videos[numVideos++] = tokenId;
}
}
require(chill__inProgress, "Not started yet") 调用用于检测状态变量 chill__inProgress 是否为 true (取决于 minter 是否已经调用了 chill__start 方法)
uint256[] memory images = new uint256[](info.images.length);
uint256[] memory videos = new uint256[](info.videos.length);
上述代码是初始化一段数组,数组的长度取决于 minter 调用合约是传入的参数长度,通常info.images.length 为 15 ,info.videos.lengh 为 2。
uint256 numImages = 0;
uint256 numVideos = 0;
for (uint256 ii = 0; ii < info.images.length; ++ii) {
uint256 tokenId = info.images[ii];
if (ownerOf(tokenId) == info.user) {
images[numImages++] = tokenId;
}
}
for (uint256 ii = 0; ii < info.videos.length; ++ii) {
uint256 tokenId = info.videos[ii];
if (ownerOf(tokenId) == info.user && !chill__videoUsed[tokenId]) {
videos[numVideos++] = tokenId;
}
}
上述代码是为了将参与合成的数字藏品 tokenId 记录到 uint256[] memory images 及 uint256[] memory videos 中,其中普通款参与合成的藏品 tokenId 记录到 images ,隐藏款参与合成的 tokenId 记录到 videos中。
if (ownerOf(tokenId) == info.user) {
images[numImages++] = tokenId;
}
if (ownerOf(tokenId) == info.user && !chill__videoUsed[tokenId]) {
videos[numVideos++] = tokenId;
}
上述代码中很关键的逻辑其实在于 if 语句中的条件,ownerOf(tokenId) == info.user ,其中info 是调用者传入的参数 info.user 对应的内容即为账户(0x18b1edd5ae7...),ownerOf能够判定 tokenId 对应数字资产的所属账户,说明合约开发者希望避免自己调用时通过 info 指定的参数出现错误,必须要求确实持有该数字资产的用户才能够参与合成。numImages++,能够记录账户参与合成的普通款藏品数量,有的账户可能有30个普通藏品,加4个隐藏藏品,则能够合成两次,获得两个合成藏品。有的账户可能有更多,不信,可以参考如下交易:
https://www.confluxscan.net/transaction/0xfdb70f2122896f402b4e97231b6c3983fc43606b1c049acd1760265df2225b5d
!chill__videoUsed[tokenId] 这一检测逻辑其实更为简单,它维护了一个全局状态,解决了如下需求:在不销毁隐藏款数字藏品的前提下,知晓该数字藏品是否已参与过合成操作。如果没参与过合成,才能够参与合成操作。假设 tokenId == 1 的藏品参与过合成操作,则chill__videoUsed[1] 将被设置为 true。
uint256 imageId = 0;
uint256 videoId = 0;
while ((numImages - imageId) >= numImagesForReward && (numVideos - videoId) >= numVideosForReward) {
// burn images
for (uint256 ii = 0; ii < numImagesForReward; ++ii) {
uint256 tokenId = images[imageId++];
_burn(tokenId);
}
// skip videos
for (uint256 ii = 0; ii < numVideosForReward; ++ii) {
uint256 tokenId = videos[videoId++];
chill__videoUsed[tokenId] = true;
}
// send out reward
emit ChillExchange(info.user, chill__nextRewardId);
this.transferFrom(address(this), info.user, chill__nextRewardId++);
}
上述代码是合成所实现的逻辑是一个 while 循环中嵌套了 for 循环, while 循环条件如下:
(numImages - imageId) >= numImagesForReward &&
(numVideos - videoId) >= numVideosForReward, imageId 及 videoId初始值设置为0,并随着合成的进行,配合 for 循环进行自增(++ 操作),numImages 及 numVideos 分别是账户中持有普通款藏品的数目和隐藏款藏品的数目。 numImagesForReward 固定为 15,numVideosForReward 固定为 2。所以这段逻辑不言自明:只要用户持有的藏品数还足够合成,就继续合成下去,直到用户账户中的藏品不够参与合成了。
numImages 及 numVideos 变量在前序逻辑中已进行了介绍,他们主要解决的问题就是记录用户到底有多少可以合成的资产,比如有个用户可能持有了45个普通款数字藏品和6个隐藏款数字藏品。则这个 while 循环可以帮助他合成3次。
for (uint256 ii = 0; ii < numImagesForReward; ++ii) {
uint256 tokenId = images[imageId++];
_burn(tokenId);
}
for (uint256 ii = 0; ii < numVideosForReward; ++ii) {
uint256 tokenId = videos[videoId++];
chill__videoUsed[tokenId] = true;
}
上述代码的逻辑就比较简单了, numImagesForReward 固定为15 ,numVideosForReward 固定为 2 。for 循环在此处的作用相当于 while 循环的子循环,通过维护临时变量 imageId 及 videoId 判定是否还能继续合成。对于普通款藏品(第3行),会调用 _burn 函数进行销毁(转移至0地址),对于隐藏款藏品(第7行),将标志位 chill__videoUsed[tokenId] 设置为 true(这样该隐藏藏品就不能再参与其他合成操作了)。
emit ChillExchange(info.user, chill__nextRewardId);
this.transferFrom(address(this), info.user, chill__nextRewardId++);
最后的逻辑主要就是两行代码,通过 emit 操作通知区块链,info.user 对应的用户已经合成完成,合成藏品的编号 ID 为 chill__nextRewardId,并调用 transferFrom 方法将chill__nextRewardId 对应的藏品转发至 info.user 对应的账户中。由于++是先使用当前值,再自增,所以能够确保下一次合成所生成的数字藏品编号是正确且不重复的。
本章内容仅结合合约代码逻辑进行安全性问题识别与探讨,并不代表实际对应合约存在问题,经研究分析我们提炼了该合约潜在存在的安全问题:
(1)minter 的权限滥用
(2)具备合成条件,但不想参与合成的用户,同样会被代码自动合成;
(3)如果普通用户具备调用合约的条件,则可以通过构造函数的方法,将 numImagesForReward 及 numVideosForReward 设置为更低的值,使本身不符合15个普通藏品及2个隐藏藏品的用户,同样能够合成数字藏品。
(4)代理合约所带来的替换风险。
六、威胁情报共享
近期有 KOL 发现在 OpenSea 上存在空投数字藏品配合 offer 的攻击,如果点击接受 offer 则账户持有的资产将被自动转移。
藏品地址:
https://opensea.io/assets/ethereum/0x5e8ff95473d1eb96e2099cce5f6f171aac115947/58
提供 offer 操作的地址:
0x7747bb451f7a979fa7c9830b3b0a99903a921972
请大家在使用时,注意资产及授权安全。
七、参考资料及鸣谢
(1)奥DIONYSOS 公众号所提供的合成玩法预告:
https://mp.weixin.qq.com/s/AHn9xEnwtJdKZLnQK19S6g
(2)ConfluxScan 浏览器
https://www.confluxscan.net/
(3)鸣谢:SeeDao安全学习小组为编写本文所提供的思路与技术指导。