数字藏品合成公告,合约审计及安全问题探讨
免责声明:本文不构成任何投资建议,文中所涉及的技术及相关安全问题仅代表笔者观点,不具有任何指导和买卖意见。市场有风险,投资需谨慎!识别出的潜在安全问题,并不代表该合约在实际环境中就确实存在安全问题,识别出的风险点仅作为安全建议进行参考。
截图如下所示,通读说明可以提取如下关键字样:
(1)普通款藏品已经合成即“自动销毁”,隐藏款合成后,将仍会保存;
(2)合成是自动完成的:也就是说可以在持有者无感的情况下,自动持有者的普通藏品销毁,保留隐藏款,并向成功合成的用户 transfer 一个合成后的数字藏品。
考虑到,在不掌握私钥的情况下,除私钥持有人外,任何区块链账户都不具备直接销毁用户资产的能力。因此,如果需要具备自动合成及销毁普通款藏品的能力,需要开发者在数字藏品的合约代码处添加相关逻辑:
(1)记录钱包内普通款藏品数据;
(2)对钱包内隐藏款藏品进行记录;
(3)调用 NFT 合约中提供的 burn 方法,将普通款藏品销毁;
(4)调用 NFT 合约中提供的 mint 方法,将 “合成” 的藏品发行,并转移至合成成功的用户钱包处。
当上述逻辑执行完成后,用户钱包内的资产将变为合成藏品(从零地址mint 出来并转移至用户钱包),而普通款藏品将消失(由于 burn 至零地址),隐藏款藏品保留(说明代码逻辑不会对隐藏款藏品调用 burn 方法)。这样,开发者就可以在不需要账户私钥的情况下,完成这一操作。
从安全的角度来看,这一逻辑可以理解为是一种安全可控的后门:在特定情况下触发,执行敏感操作(调用 burn 方法销毁一部分资产,调用 mint 方法增发合成后的数字藏品),在不滥用和误用的前提下,用户资产是安全的。Code is Law,只要用户账户里存在15个或以上的普通藏品及2个或以上的隐藏款藏品,代码即默认认为用户愿意参与销毁15个普通藏品,消耗2个隐藏款藏品,并以此兑换一个合成后的数字资产的合成活动。
区块链操作是不可篡改的,且所有的链上交互都是公开可查的,因此我们选取了一笔“合成”交易,进行行为分析。
合成操作对应的交易数据:
https://www.confluxscan.net/transaction/0xed942a78a265a05c4e0647817918ce654bf6cba6a3512eb873404e4b4100080d
行为分析如下所示:
(1)cfx:aatgvya99cctj5kx0agh4b18czbav2m80adsrny7ah 是调用智能合约方法的账户,通过发送一笔合约交互的交易,这笔交易可以理解为触发智能合约按照特定代码逻辑执行操作的激励(通过发送区块链交易,调用智能合约特定方法)。
(2)cfx:acgzemzu90x4zjku12f6v05kr8f832j6t6esp9jk62 为被调用的智能合约,在智能合约部署的那一刻,该智能合约的逻辑及执行行为将无法被任何人篡改。
(3)由于每一个数字藏品都对应于区块链上的一个 Token ,在进行 Token 转移时,相关行为将被区块链所记录,通过下图我们可以发现, ID 为6422,6805,7086,7190的数字藏品(截图未截全,只显示了4个转移操作,而实际上该地址一共转出了15个藏品到零地址)直接转移至零地址,实现销毁。而智能合约会向合成成功的用户转移 ID 为8196的新数字藏品(即合成款数字藏品)。
调用智能合约的输入数据经解码为 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安全学习小组为编写本文所提供的思路与技术指导。
]]>