こんにちは!CryptoGamesというブロックチェーンゲーム企業でエンジニアをしているかるでねです!
スマートコントラクトを書いたり、フロントエンド・バックエンド・インフラと幅広く触れています。
このブログ以外でも情報発信しているので、よければ他の記事も見ていってください。
https://mirror.xyz/0xcE77b9fCd390847627c84359fC1Bc02fC78f0e58
今回は「ハニーポット」について解説していきます!
「ハニーポット」は基本的にハッカーなどの攻撃者をおびき寄せるために使用されるものです。
Bug Bountyシリーズでは脆弱性の対策をしてきましたが、今回はいつもとは違った視点が必要なので楽しんで学んだいってください!
Solidityスキルを1段階あげたい方やバグ・バウンティに参加したいバグハンターを目指している方は必須の知識となるので、この記事で学んでいってください。
バグ・バウンティが何かわからない人は以下の記事を読んでください。
ハニーポットとは?
まずはハニーポットの言葉の定義を確認していきましょう。
ハニーポットの定義
Wikipediaでは以下のように書かれています。
ハニーポット (英: honeypot) は、コンピューターセキュリティにおいて、悪意のある攻撃を受けやすいように設定した機器を、おとりとしてネットワーク上に公開することにより、サイバー攻撃を誘引し、攻撃者の特定や攻撃手法を分析する手法、あるいは、そのような用途で使用するシステムをいう[1]。
ハニーポットを設置する目的は、ウイルスやワームの検体の入手、不正アクセスを行うクラッカーをおびき寄せ重要なシステムから攻撃をそらしたり、記録された操作ログ・通信ログなどから不正アクセスの手法と傾向の調査を行うなどである。
https://ja.wikipedia.org/wiki/%E3%83%8F%E3%83%8B%E3%83%BC%E3%83%9D%E3%83%83%E3%83%88
簡単にまとめると、「ハッカーを引き寄せるようにシステムを設計し、ハッカーがどのように行動し、何を狙っているのかを確認する」ことがハニーポットの目的です。
スマートコントラクトにおけるハニーポット
ハニーポットの定義を確認できたところで、スマートコントラクトにおけるハニーポットはどんなものなのかを説明していきます。
スマートコントラクトにおいてのハニーポットは、「攻撃者に脆弱性を突かせるために、意図的に作られた脆弱なコントラクト」を作成することを指します。
攻撃者から見た時に、「ここに脆弱性があるから攻撃してみよう!」と思い攻撃を実行したが逆に自分の資金が取られてしまうようなものです。
スマートコントラクトにおけるハニーポットの目的としては、「攻撃者の資金を取得する」と言うよりも、攻撃者のアドレスを取得してブラックリスト化することなどが目的です。
ただ、ウォレットであればすぐに新しいウォレットを作成できてしまうので、個人情報の提出などを必須としているCEX(中央集権型の取引所)などで上記のブラックリスト化は有効です。
ハニーポットの実装
ハニーポットとは何かを確認できたところで、早速ハニーポットを実装していきましょう!
残高障害
以下のコントラクトは資金を預けることと引き出すことができるコントラクトです。
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.13;
contract Honeypot {
// 確認用の変数
uint public balance;
uint public value;
/// @notice 資金を預けることができる関数。
function deposit() public payable {
balance += msg.value;
value = msg.value;
}
/// @notice 資金を引き出すことができる関数。
function withdraw() public payable {
balance += address(this).balance;
value = msg.value;
// コントラクトが保持している資金より多くの資金を渡して呼び出すと、
// コントラクト内の資金を全て取得できる脆弱性...?
if (msg.value >= address(this).balance) {
payable(msg.sender).transfer(address(this).balance+msg.value);
}
}
}
deposit
資金を預けることができる関数。
widthdraw
コントラクトが保持している資金より多くの資金を渡して呼び出すことで、全ての資金を取得できる脆弱性を持つように見える関数。
では実行してみましょう。
いかがだったでしょうか?
資金を引き出せると思いましたが引き出せていないことが確認できましたね。
これはコントラクトに保持している資金の方が送られた資金よりも多いため、以下のif文が実行されなかったためです。
if (msg.value >= address(this).balance) {
payable(msg.sender).transfer(address(this).balance+msg.value);
}
先ほど実行したように2 ether
がすでにコントラクトにある状態で3 ether
の資金を送金すると、コントラクトは5 ether
保持することになり、address(this).balance
の方が数値が大きくなります。
上記のため攻撃者の資金は引き出せなくなり、ハニーポットに引っかかったことになります。
一見脆弱性に思えても実は脆弱性ではないということの良い1例になったのではないでしょうか?
コードを隠すハニーポット
Solidityでは悪意あるコードを隠すことができます。
詳細は以下の記事に書かれているので目を通してもらえると理解がスムーズにできます。
CallContract
contract CallContract {
// withdraw関数を呼び出すコントラクトのアドレスを格納。
address public widthdrawContract;
// コントラクトをデプロイしたEOAアドレスを格納。
address public owner;
// アドレスごとに預けた資金を管理。
mapping(address => uint) balances;
constructor(address _widthdrawContract) {
widthdrawContract = _widthdrawContract;
owner = msg.sender;
}
/// @notice 資金を預ける。
function dposit() public payable {
balances[msg.sender] += msg.value;
}
/// @notice WidthdrawContractのwidthdraw関数を呼び出して、
/// 指定した資金を引き出す。
function widthdraw(uint _amount) public payable {
(bool sent, ) = widthdrawContract.delegatecall(abi.encodeWithSignature("widthdraw(uint256)", _amount));
require(sent, "Could not remit.");
}
}
デプロイの際に別コントラクトのアドレスを渡しています。
deposit
資金を預けることができる関数。
widthdraw
デプロイ時に渡したアドレス内に存在するwidthdraw
関数を呼び出して、指定した資金分自分のアドレスに引き出すことができる関数。
delegatecall
については以下の記事を参考にしてください。
WithdrawContract
contract WithdrawContract {
address contractAddress;
address owner;
mapping(address => uint) balances;
/// @notice 指定した資金を引き出す関数。
function widthdraw(uint _amount) public payable {
require(balances[msg.sender] >= 1 ether);
balances[msg.sender] -= _amount;
(bool sent, ) = msg.sender.call{value: _amount}("");
require(sent, "Could not remit.");
}
}
* 使用しないおとりのコントラクトです。
delegatecall
を使用する際は、呼び出し元と呼び出し先で変数の定義を同じにしないといけないため、変数を同じように定義しています。
widthdraw
資金を引き出そうとするユーザーが1 ether
以上預けているかチェックして、指定した資金を送金。
この関数を見るとユーザーが預けている資金以上の値を_amount
に指定しても引き出せてしまう脆弱性が見つかります。
HoneyPot
contract HoneyPot {
address contractAddress;
address public owner;
mapping(address => uint) balances;
constructor() {
owner = msg.sender;
}
/// @notice 預けた以上の資金を引き出そうとするユーザーの資金を逆に抜き取る関数。
function widthdraw(uint _amount) public payable {
require(balances[msg.sender] >= 1 ether);
if (_amount > balances[msg.sender]) {
uint baalnce = balances[msg.sender];
balances[msg.sender] = 0;
(bool sent, ) = payable(owner).call{value: baalnce}("");
require(sent, "Could not remit.");
} else {
balances[msg.sender] -= _amount;
(bool sent, ) = msg.sender.call{value: _amount}("");
require(sent, "Could not remit.");
}
}
}
CallContractデプロイ時にはこちらのコントラクトのアドレスを渡します。
delegatecall
を使用して呼び出すため、変数はCallContractと同じように定義しています。
widthdraw
資金を引き出そうとするユーザーが1 ether
以上預けているかとそのユーザーが預けている資金以上に引き出そうとしていないか(悪意あるユーザーか)チェックする。
悪意ないユーザーであればそのまま資金を送金し、悪意あるユーザーであればそのユーザーが保持している資金をCallContractコントラクトをデプロイしたユーザーに送金します。
百聞は一見にしかずということで実際に実行してみましょう。
いかがでしょうか?
ちょっと複雑だったので順を追って説明していきます。
処理の流れ
- ユーザーAが3つのコントラクトをデプロイしました。この際、CallContractの
widthdrawContract
変数にはHoneyPotコントラクトのアドレスが格納されています。 - ユーザーBが
3 ether
を預け、ユーザーCが2 ether
を預けたので、コントラクト内には5 ether
が溜まっています。 - ユーザーCは
widthdraw
関数を実行すると、WithdrawContract内のwidthdraw
関数が実行されると思っているため、脆弱性を見つけたと5 ether
を引き出そうとします。 - しかし、実際に呼び出されたのはHoneyPotコントラクトの
widthdraw
関数で、ユーザーCが預けた以上の資金を引き出そうとしているため、if文に引っかかりユーザーAのアドレスにユーザーCが預けた2 ether
が送られました。
ちなみにwidthdraw
関数実行時に5000000000000000000
を指定しているのは以下の理由があります。
単純に1と指定すると1 ether
ではなく1 wei
となってしまうため、1 ether = 10^18 wei
という式を利用して5 ether = 5000000000000000000wei
を指定しています。
どうでしょうか?
悪意あるコントラクトやdelegatecall
への理解が必要なので1つずつ理解していってください!
コード全体
コード全体は以下になります。
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.13;
contract CallContract {
// withdraw関数を呼び出すコントラクトのアドレスを格納。
address public widthdrawContract;
// コントラクトをデプロイしたEOAアドレスを格納。
address public owner;
// アドレスごとに預けた資金を管理。
mapping(address => uint) balances;
constructor(address _widthdrawContract) {
widthdrawContract = _widthdrawContract;
owner = msg.sender;
}
/// @notice 資金を預ける。
function dposit() public payable {
balances[msg.sender] += msg.value;
}
/// @notice WidthdrawContractのwidthdraw関数を呼び出して、
/// 指定した資金を引き出す。
function widthdraw(uint _amount) public payable {
(bool sent, ) = widthdrawContract.delegatecall(abi.encodeWithSignature("widthdraw(uint256)", _amount));
require(sent, "Could not remit.");
}
}
contract WithdrawContract {
address contractAddress;
address owner;
mapping(address => uint) balances;
/// @notice 指定した資金を引き出す関数。
function widthdraw(uint _amount) public payable {
require(balances[msg.sender] >= 1 ether);
balances[msg.sender] -= _amount;
(bool sent, ) = msg.sender.call{value: _amount}("");
require(sent, "Could not remit.");
}
}
contract HoneyPot {
address contractAddress;
address public owner;
mapping(address => uint) balances;
constructor() {
owner = msg.sender;
}
/// @notice 預けた以上の資金を引き出そうとするユーザーの資金を逆に抜き取る関数。
function widthdraw(uint _amount) public payable {
require(balances[msg.sender] >= 1 ether);
if (_amount > balances[msg.sender]) {
uint baalnce = balances[msg.sender];
balances[msg.sender] = 0;
(bool sent, ) = payable(owner).call{value: baalnce}("");
require(sent, "Could not remit.");
} else {
balances[msg.sender] -= _amount;
(bool sent, ) = msg.sender.call{value: _amount}("");
require(sent, "Could not remit.");
}
}
}
最後に
今回は「ハニーポット」について取り上げてきました!
いかがだったでしょうか?
ポイント
- 「ハニーポットがどういったものか理解できた!」
- 「ハニーポットの実装方法を理解できた!」
- 「ハニーポットの仕組みを理解できた!」
上記が当てはまっていたら嬉しいです!
もし何か質問などがあれば以下のTwitterなどから連絡ください!
普段はSolidityやブロックチェーン、Web3についての情報発信をしています。
Twiiterでは気になった記事などを共有しているので、ぜひフォローしてくれると嬉しいです!