参加了ETHPanda的Web3实习计划,说是要每日打卡,这里就当作打卡暂存处吧(主要是先在这里写了方便直接复制粘贴打卡www)
说是每日笔记打卡,倒不如说更像是每笔日记打卡(笑)
2025.8.4
太久没学Web3了,今天作为打卡第一天,先稍微做点简单题热身一下吧
下面是来源于Cyber Apocalypse CTF 2025的签到题Eldorion:
1 | // Eldorion.sol |
由题意我们可以知道我们需要击败300血的Eldorion,但是每次攻击最多只能攻击100点血量,而且每次攻击会将上次攻击时间和本次攻击时间进行比对,如果本次攻击时间大于上次攻击时间则Eldorion会回满血
看起来这就是一个无底洞,但是我们可以轻松发现记录攻击时间使用的是block.timestamp
,即区块生成时间,因此我们只需要让多次攻击处于同一区块内就可以绕过时间检测,同时需要注意不要Overkill,否则攻击会失败
1 | // attack.sol |
部署后调用attack()
即可完成本题
是不是有点太水了,那就接着再水一题吧,是同一个CTF的第2题HeliosDEX:
1 | // HeliosDEX.sol |
由于比赛已经过了很久了,我也不知道自己手上有多少初始ETH,假定是0.1ETH吧,同时为了方便在Remix调试(我懒得自己搓一个Foundry script了),稍微降低一下Setup中的参数,最终根据实际情况预定是传入99 ether(考虑到gas费无法传入100 ether),初始参数同样设置为99,
isSolved
等比修改为2 ether
这题提供了一个能交换3种ERC20的DEX,每种代币都有其独特的汇率,最后的refund只能兑换1种代币,从合约来看自然是HLS汇率最好,能和ETH达到1:0.1的汇率;除此之外,兑换代币和refund的时候都会收取2.5‰的手续费
观察一下合约,refund被一个reentrancy guard修饰,且只有该函数能转出ETH,因此重入攻击打不了,所以想解题只能尽可能多兑换代币
观察三种代币的兑换方式,我们发现它们都使用了OZ的mulDiv
函数,但是其参数中的Math.Rounding
十分可疑,因此我们查看OZ的合约源码:
1 | library Math { |
那么我们会发现兑换ELD,MAL,HLS时,计算得到的代币数量分别会向下取整,向上取整,向上取整(由于不可能兑换负数个代币),因此我们可以仅使用1 wei兑换1个MAL或者1个HLS;同时由于整数除法导致的精度损失,我们兑换代币的数量只要不到400,就能忽略手续费,因此我们可以编写如下的攻击合约:
1 | // SPDX-License-Identifier: MIT |
需要注意一下,对于原题,使用for循环可能导致gas费消耗问题,可以稍微更改一下,此处仅作为思路提供
同时在最后进行refund前需要首先向DEX进行Approve操作,否则DEX的
transferFrom
会失败导致revert,同时由于转钱是转给msg.sender
,因此需要给合约添加receive()
函数接收转账,并添加提款函数提走合约内的资金以完成本题
行吧,就这样了,开摆~
2025.8.5
凌晨学完,白天就不用学了(确信)
接着看看CACTF的压轴题EldoriaGate吧:
1 | // EldoriaGate.sol |
由题意我们需要使得checkUsurper()
返回为True,而与之对应的是要让我们的role为0,其默认的值为ROLE_SERF
即1
说句实话题目合约真没有什么能让我们交互的,因此说白了就是通过调用enter()
来实现修改role的操作,那就让我们一步步看:
首先会进行一次authenticate(bytes4 passphrase)
去匹配密码是否正确,而密码被存放在Kernal的Storage Slot 0处,因此我们直接用cast去获取指定存储槽内数据即可
通过认证后会调用Kernal的evaluateIdentity
,而其中的contribution
参数为我们发送的msg.value
,好巧不巧Kernal中的实现里面判断role是通过add(defaultRoleMask, _contribution)
实现的,虽然后面有lt
小于判断,但是由于使用的是assembly,而且role是uint8数据类型,因此会存在整型溢出,所以只需要让_contribution
为255就能让我们的role为0,从而解出本题
由于使用了非合约操作进行解题,这里就不放解题合约了,总之就是使用cast storage --rpc-url "your rpc url" "kernal address" 0
读取Storage Slot获取密码后调用enter{value: 255 wei}(password)
即可
暂且这样吧,明天开始复现一下那些纯Blockchain的CTF题
以防有人说我水,这里再放一个RemedyCTF最简单的题(除去签到题)Diamond Heist的Writeup,附件这里就不给出来了,6个合约杀了我吧(
这道题总共有6个合约,看起来非常恐怖,但是实际上是所有题目里面解出数第2高的(第1是签到)
- 首先分析Challenge.sol,里面的要求非常简单:获取所有的Diamond代币即可,然后初始给了自己10000 ether的HexensCoin代币,并且将所有的Diamond代币转到了Vault里
- VaultFactory.sol和名字一样,就是一个合约工厂,通过salt生成Vault合约,所以使用的是
CREATE2
操作码,至于使用的salt在Challenge.sol里面有,此处按下不表 - Diamond.sol很直白,就是一个普通的ERC20合约,知道这一点就行
- Burner.sol,一个销毁用合约,很直白
- 接下来是两个核心合约:首先来看HexensCoin.sol,这个合约也是一个ERC20合约,唯一的差别就是它添加了一个delegate机制(委托机制):根据委托人所持有的HexensCoin数量,增加被委托人的Votes(即票数),而用户所持有的Votes数量仅在调用
_moveDelegates
函数的时候进行更新,且获取Votes仅会读取上一个Checkpoint的Votes数 但是问题就在于委托人将自己持有的HexensCoin通过transfer转移给其它用户的时候,被委托人的Votes仍旧不会发生改变(缺少了beforeTokenTransfer
的override),因此这会导致Votes数可以刷上去,比如统一给A刷票,那首先让B调用delegate(address(A))
后将自己的HexensCoin转给C,再让C进行delegate,以此类推 - 最后是Vault.sol,这是一个UUPS可升级代理合约,说明我们可以对这个合约进行升级,同时可以指定合约的implementation,也就是说我们可以尝试通过升级这个合约来达成恶意的操作;至于
governanceCall
我们可以通过刚刚提到的刷票方法进行调用,而burn
的话则是可以让我们“销毁”掉Vault中所有的Diamond,至于为什么带双引号这里先卖个关子
注意看题目附件中的Challenge.py,里面说明本题区块链的分叉为Shanghai分叉,也就是说此时selfdestruct
仍然可以在constructor以外被调用时删除合约字节码,因此我们可以销毁掉Vault再重新部署一个新的Vault并初始化,这样我们就拥有了金库的所有权;同时这个金库还可以升级为添加了恶意函数的金库,从而达成获取所有Diamond代币的目的
接下来我们回收一下伏笔:Burner自毁的操作,实际上只销毁了所有的ETH(甚至也没有,因为参数是payable(address(this))
,也就是把ETH发送到本地址后销毁,ETH还在这个地址),而ERC20并不会随之消失,因为ERC20本质上还是一个合约,而ERC20的数量本质上也是合约的一个mapping的value,因此“被销毁的Diamond”仍然保留在创建的Burner的地址中
那么此时就到了高潮部分:已知创建Burner的时候使用的语句是new,说明使用的是CREATE操作码,而nonce是1(即Burner是Vault创建的第1个合约),所以如果我们重新创建的Vault的地址是一样的,那么创建的Burner的地址也是一样的;而创建Vault的时候使用的又是CREATE2,合约工厂地址固定、salt已知,所以我们只需要控制新创建的Vault的init_code(也就是constructor部分)一致就能保证新建的Vault一定是原先的Vault的地址
因此我们可以给原先的Vault添加2个新的函数:(x.sol
)
1 | // SPDX-License-Identifier: UNLICENSED |
我们这个恶意的Vault添加了自毁函数以方便我们重新部署合约并获得owner权限,同时添加了new_sender
函数创建并使用我们恶意的Burner合约将“被销毁”的Diamond重新发送给恶意Vault并最终发送到我们自己手上
恶意的Burner合约如下:(Burner2.sol
)
1 | // SPDX-License-Identifier: UNLICENSED |
那么至此我们的攻击路径就基本完成了:
可能有人会问:为什么要销毁再创建新的Vault?我升级后直接调用new_sender不就好了?欸,还记得CREATE操作码的nonce是什么吗?是这个合约已创建的合约数+1,也就是说如果此时调用new_sender那么nonce是2,那么我们是无法获取到Diamond的(因为压根就不在那),销毁是为了重置这个nonce,就这么简单
那么这里就给出一个(应该能够运行的)脚本吧,别被吓到了,大部分都是合约的ABI,核心逻辑在后面
1 | from web3 import Web3 |
2025.8.6
欸,RemedyCTF还有一个最简单的题目Rich Man’s Bet,下面是Writeup(附件也不给了)
这题相比前面的Diamond Heist,合约数量大幅降低,但是作为一个治理合约这题难度也不低(虽然也是并列第3高解出的题目)
那么还是老样子,先分析一下合约内容:
- AdminNFT.sol是一个基础的ERC1155合约,这种合约支持多个代币的存储和批量转发
- Challenge.sol里面包含了我们的最终目标:完成“挑战”并且将跨链桥合约的余额全部转走(虽然这题并没有也不需要跨链),至于挑战就是非常简单的3个数学题,随便代进去几个数就做出来了
- 核心合约Bridge.sol是一个ERC1155Receiver合约,说明这个合约支持ERC1155的多代币批量转发操作,同时我们也能看到有个
onERC1155Received
,即接收到ERC1155转入的代币会自动调用该函数,同理还有个onERC1155BatchReceived
会在接收到批量转发的代币的时候被调用 当然作为治理合约,肯定有治理逻辑,这里就是通过NFT的Power进行治理的:Admin持有的NFT的Power是10000 ether,而一般的NFT只有50 ether,而更改跨链桥设置需要所有签名人的Power大于总数的一半且都为Validator,而最终的withdrawEth
则需要所有支持提款的总人数超过阈值(Challenge.sol设定为10)且都在初始设定的withdrawValidator
名单中,但是很明显除了合约自己我们谁都不在名单里面…
好的,现在让我们开始做题,首先我们可以很快得到三道数学题的一组合法解,解出来后在Bridge中验证挑战即可完成isSolved
的前面2个要求:
1 | def main(): |
接下来我们进行提款,上面提到了没有人能够提款,因此我们只能尝试让threshold变为0,而唯二能更改threshold的值的地方只有constructor和changeBridgeSettings
两个地方,也就是说我们就是得更新设置,那么我们有需要成为Validator,而能成为Validator的地方就只有两个onERC1155(Batch)Received
了,也就是说我们就是得向Bridge转入代币…吗?
仔细查看ERC1155的实现,我们发现如果id和amount两个数组为空数组(即转入0种代币)也是会正常调用目标ERC1155Receiver的onERC1155BatchReceived
函数的,而Bridge中并没有检测转入的代币种类是否为0,所以我们只需要转入0种代币即可:
1 | make_validator = lambda acc: send_transaction( |
然后进入到changeBridgeSettings
的部分,我们发现验证签名是否有效的时候只会记录lastSigner
并和其比对,那么我们只需要有至少2个签名就能进行无限的验证,从而达到刷Power的目的,因此我们只需要再新创建一个地址并让它签一下名就行了,当然前置是需要这个地址也成为Validator;修改的设置中我们只需要修改threshold,但是这个新的threshold必须大于1…看起来没有办法,但是仔细看一下函数传入的数据类型:是一个uint256,而最后使用的threshold是一个uint96,中间进行了一次显式类型转换,也就是说如果我们传入的数很大,类型转换会产生数据丢失,比如我们传入的newThreshold是$$2^{96}$$,那么最后我们会有threshold = uint96(newThreshold) = 0
所以我们根据上面的理论更新完设置后直接withdrawEth就行了,签名列表为空就行,最后的脚本如下:(有些多余的ABI可以删掉,因为没有调用到)
1 | # 3f716403f4394aa3c38997b1aeebed19 |
老传统,再接一题吧,同个比赛的题目Frozen Voting:
根据提供的题目合约,我们可以知道ADMIN在mint了一个10000票权的NFT后delegate给了我们自己,同时我们自己有一个1票权的NFT,此时我们的票权为10001;而最后的isSolved
会尝试更换delegate并转移走10000票权的NFT,而我们的目标就是阻止这次操作
那么其实很明显了:我们唯一能做的就是在第一步更换delegate的时候做手脚,而NFT在发生所有权变更的时候一定会调用_delegate
,而其中的_moveDelegates
会减去上一任delegatee的票权并加到下一位那里,因此很简单:我们要想办法通过_delegate
让自己的票权小于10000,从而触发整型下溢以实现DoS攻击
经过一点简单的代码审计,我们发现delegateBySig
函数有点小小的问题:相比delegate
函数,它缺少了对delegatee
参数是否为address(0)
的确认与操作,从而使得我们可以将自己的NFT通过delegateBySig
函数delegate给0x0地址,而根据_moveDelegates
函数,此时我们的票权会-1,而没有人的票权会上升(合约误以为我们销毁了NFT),因此此时我们再一次进行delegateBySig
或者直接transferFrom
给任意的地址即可让我们的票权达到9999,进而实现前面的目的
下面是forge的解题script:
1 | // SPDX-License-Identifier: UNLICENSED |
2025.8.7
我靠昨天看完沪赛的题目睡过去了,好悬没签上到
今天稍微摆个烂,就只看1题吧,RemedyCTF的Lockdown:
整个题目给我眼睛都看花了,现在还是有点懵,这题能有23解是不是有点变态了(
整个题目设计了一个能质押NFT并获取质押奖励的市场和一个对应的NFT合约,同时大量使用了安全的运算函数以及重入哨兵,代码量也不小,乍看起来十分唬人
但是在Marketplace合约中,我们发现Unstake
函数在safeTransferFrom
后会进行swapCUSDCforUSDC
操作,而传入的参数recipient
(prevOwner
)和函数中的_iLockToken.ownerOf(tokenId)
(NFT实际持有者)可能会不一致,最后导致本应该发放给NFT持有者的奖励被发放给prevOwner
,因此我们可以从这里开始下手
同时我们还发现Token中对beforeTokenTransfer
的实现存在问题:如果from
或者to
为市场本身的时候,则不会修改prevOwner
,因此假如我们已经让prevOwner
获了一次利,此时我们直接再质押并取消后,就能产生双倍利润;除此之外,只有beforeTokenTransfer
会修改prevOwner
,因此我们需要首先进行一次带onERC721Received
的重入操作,在第一次质押并取消的时候触发重入,让prevOwner
修改为我们一个Alt Account,此时我们的prevOwner
在取消质押的时候就能设置一个虚空的_cUSDCInterest
后获取质押奖励;第2次则不需要重入,直接质押并取消即可再次获利,从而翻倍奖励
这里附上榜一大哥们公开的解题脚本,我到现在还有点懵圈所以就没有自己写QAQ
1 | // SPDX-License-Identifier: UNLICENSED |
2025.8.8
今天想摆烂,就不刷题了,想着下周要搞个dApp,干脆顺带把下一届新生赛的题目一起出完好了,没几个月了
今天题目设计的进展也不多,就是跟着Chainlink文档搓了个D100用于NFT抽卡,基本上照搬的,不过只是获取随机数而已,谁还不是套个板子就开始跑呢?(笑)
D100在Ethereum Sepolia上的地址:0xA6FAca145A93DC2fAcd749B7303ef950ba2A6d81
,已经在Etherscan上Verify了源码,这一个Version还没有添加重置骰子的操作,之后会添加(不然一个人就只能抽1个NFT有点太尴尬了www),后面可能会添加NFT稀有度合成的操作,不过那些都是后面的事了,还得想想在哪个不起眼的地方塞个漏洞进去(o-ωq)).oO
Chainlink VRF这个投骰子耗时挺长的,1分钟,还得想想前端页面该怎么搞才能算合理,总不能像碧蓝航线搞个造船时间吧(
顺带一提,Remix的Verify插件挺逆天的,需要你传入正确的constructor才能Verify,结果传进去一个差不多1e76的数告诉我Overflow(2**256>1e78),但是在数前面加个0就好了,气笑了
运气不好,自己只抽到个R,笑死
2025.8.9
周末了,姑且休息一天,明天再战
随便看了一下账户抽象的一些Approach(文章链接:Account Abstraction: Past, Present, Future),准备开始啃EIP了,刚好Pectra已经在使用EIP-4337了,说不准还可以实战测试一下
说白了就是用户对持有的资产的管理和行为不够灵活,所以想要实现所谓的“账户抽象”,最终的目的是让钱包能够执行自定义的代码,而途径无非就两种:
- 让智能合约能够“主动”发送交易(一般的合约不能主动发送交易,它只是一系列确定且公开的执行逻辑,也就是一个没有电源的电路)
- 让EOA能够执行代码(一般的EOA的
extcodesize
为0,这也是过去判断EOA地址和合约地址的一个重要依据)
至于对每种途径提出的EIP等明天再开始看,现在该睡觉了
2025.8.10
嘻嘻,昨天口糊了,Pectra使用的是我们的EIP-7702而非EIP-4337(人家现在还只是草案阶段呢,不过确实是被广泛接受了,23年就已经在主网部署了Entrypoint.sol
)
今天先简单写一点ERC4337吧
交易过程的差异
对于一般的EOA账户交易,我们都是使用EOA的私钥对交易数据进行签名,然后账户将这些签名数据发送到以太坊节点进行验证和执行,验证后在链上执行操作
这里就有很多问题:首先就是经典的“私钥即一切”的问题:丢失了私钥那么就彻底丢失了资产以及相关的控制权;同时交易的支付代币只能是ETH,签名算法只能是ECDSA,EOA不够灵活…
因此有人提出了个想法:将Owner(资产持有者),Signer(交易发起者,签署交易)和Gas Payer(手续费支付者)从现有的框架中解耦出来,最终得到的一个结果就是ERC-4337
在此之前有EIP-2938及EIP-3074等提出了解决方案,但是有些方案是中心化的,有些则需要从共识层协议进行改动,需要进行硬分叉,而ERC-4337是在原有框架下实现最大程度的AA的一个方案
ERC-4337新提出了一个叫做UserOperation
的一个类交易结构体,里面包含一系列类似普通交易的参数,比如常规的sender
和nonce
之类的,但是其中有三个新的概念:Bundler
,PayMaster
和Factory
,这里我们稍后提到
这里的UserOperation
需要进行签名,不过是可以以其它的签名方式进行签名的,而不仅仅是ECDSA,在签名后会将签名数据发送到一个独立的内存池(Alt mempool
),这个内存池只存放UserOperation
,然后会有一些支持ERC4337的以太坊节点从池中选出若干个UserOperation
进行打包,这些节点就是Bundler
(打包者)
验证签名合法性的逻辑需要提前编写在AA Wallet(Account Abstract Wallet,账户抽象钱包)中,以便之后验证签名的合法性
目前为止我们的交易还在链下,接下来就要进行链上操作了:Bundler
在打包后会将这些打包后的交易发送到一个Entrypoint
合约中进行上链操作,流程如下:
- 检测AA Wallet是否存在,如果不存在且
initCode
非空则会通过factory
地址进行一个新AA Wallet的部署 - 调用AA Wallet的函数验证签名合法性
- 检测
PayMaster
是否存在抵押在Entrypoint
的代币(以防止DoS攻击),且PayMaster
余额是否足以垫付交易 - 调用
PayMaster
中的函数进行支付检查,比如AA Wallet是否有足够的余额进行交易操作 - 执行
UserOperation
中的主要逻辑 - 返还
PayMaster
垫付的费用,并向节点支付打包费用
需要注意的是:每条链上的
Entrypoint
合约是唯一的,主网上的合约地址和实现:Entry Point 0.7.0 | Address: 0x00000000…6f37da032 | Etherscan
通过这种方式,我们可以直接实现自定义签名算法、多签钱包、多笔交易执行、多代币支付手续费等便捷操作,但是由于这种方案一定会发生合约调用,因此gas费会更高,因此其中一个优化方案是利用Layer2
2025.8.11
昨天写了AA的其中一类方案:升级智能合约钱包,使其能够主动发送交易,那今天就写一下另一类方案吧,就选比较有代表性的EIP-7702吧
昨天有关
Bundler
的表述有些不清楚,实际上那些打包的节点并非以太坊RPC节点,它们只是一系列白名单中的实体,它们只负责将打包后的UserOperation
发送到以太坊节点中,可以简单理解为矿工至于zkSync链原生实现的AA这里就不谈了,交易大致流程是一样的,只是
Bundler
同时也是以太坊节点
EIP-7702提出了一个新的交易类型0x04(另外三种交易类型0x01~0x03分别对应EIP-2930,EIP-1559和EIP-4844),该交易类型会修改指定地址authority
的代码为0xef0100 || address
,其中0xef
为EIP-3541中定义的被禁用的操作码,这里用于指代执行该地址(记为A)的代码时和执行一般代码要有所区别:需要读取指定地址(记为B)的代码后再在A的上下文中执行(很显然汇编底层上使用的是DELEGATECALL
操作码)
整个EIP-7702本质上就是将一个EOA包装成了一个Proxy,而交易类型0x04就是一个setImplementation
函数,调用EOA地址的代码本质上就是在和一个Proxy交互,本身是挺不错的,不过如果同一个地址通过EIP-7702进行多次代码设置,那么执行代码的时候会不会出现和delegatecall()
一样的问题呢?比如经典的存储内变量排布问题…
除此之外,由于部署合约时initCode
是即用即删的,因此实际上我们无法在设置代码的同时进行初始化,而是必须要在设置结束后再额外进行一次初始化,这可能会是一部分额外的gas费用
好消息是经过EIP-7702设置代码的EOA地址,其EXTCODESIZE
大小固定为23(len(0xef0100 || address)
),且CODESIZE
返回的为指定地址address
的CODESIZE
,因此本质上extcodesize检查稍微改动一点还是勉强能用的,但是如果攻击者通过汇编构造合约代码的话…那就另当别论了(笑)
根据EIP规范文档,整个EIP-7702交易的Gas费用消耗为21000 + 16 * non-zero calldata bytes + 4 * zero calldata bytes + 1900 * access list storage key count + 2400 * access list address count
,额外加上PER_EMPTY_ACCOUNT_COST * authorization list length
以及对交易执行过程中特定地址的冷/热读取费用
说了这么多,实际上整个EIP-7702是一个非常大胆的尝试,因为让EOA本身能够执行代码逻辑这件事就很危险,攻击者可能会通过一系列伪造执行恶意操作(比如设置一个智能钱包,但是你往里面存钱就会调用某个EOA的代码从而窃取虚拟资产之类的),而且本身此方案如果涉及到跨链层面还会有更多问题,因此可能需要额外的EIP进行f
2025.8.12
这一周刚好从今天开始连续5天都有事没啥空,希望能尽量挤出点时间来水下签到,别似了(笑)
想到自己之前的博客还有坑没填完,这里填一下,之后找个时间从这里填回去,今天就只写一个只读重入(Read-only Reentrancy)攻击吧(其它的重入攻击或多或少写过一点,除了Double Entrypoint)
漏洞合约和攻击合约源码来自DeFiVulnLabs/src/test/ReadOnlyReentrancy.sol at main · SunWeb3Sec/DeFiVulnLabs
本质上重入攻击的攻击点就是在于合约状态变量和现实情况之间的差别,通常是合约状态变量没能赶上现实情况的变化,从而导致攻击者能够利用虚假的变量值进行一些操作
只读重入也是类似的,差别是漏洞合约过分信任被view
修饰的函数,比如主网上的案例:Lido: Curve Liquidity Farming Pool Contract | Address: 0xDC24316b…Ea0f67022 | Etherscan
假定我们有一个依赖于上面的合约的get_virtual_price
函数提供奖励的质押合约:
1 | // VulnContract |
那么我们可以通过往Pool先add_liquidity
然后remove_liquidity
,此时根据Pool源码,我们发现Pool会首先burn掉代币然后进行转账,那么此时由于total_supply
降低,此时会导致get_virtual_price
临时升高,最终导致我们的getReward
返回更多的奖励
中途虚拟价格发生的变化就是这样的表格:
阶段 | LP代币总量 | 基础代币余额 | D值 | 虚拟价格 |
---|---|---|---|---|
初始 | X | Y | D0 | P0 = D0/X |
销毁后 | X’ = X-ΔX | Y | D0 | P1 = D0/(X-ΔX) (临时升高) |
转账后 | X-ΔX | Y-ΔY | D1 ≈ D0*(X-ΔX)/X | P2 ≈ D0/X (恢复) |
最终的攻击合约如下:
1 | contract ExploitContract { |
还是那句话:Don’t trust anyone
2025.8.13
这几天没啥时间,随便水点东西得了
- 本文作者: 9C±Void
- 本文链接: https://cauliweak9.github.io/2025/08/04/DailyCheckin/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!