🧠

ChatGPTを使ってハニーポットを実装した話

2022/12/30に公開

はじめに

「ハニーポット」とは脆弱性のあるサーバを偽装することで、脆弱性を利用された場合に攻撃者が実行するコマンドやツールを収集することができるセキュリティツールです。そのため、ハニーポット上では、複数のLinuxコマンドを安全に実行することが求められます。

しかし、Linuxコマンドは数多くあり、それぞれが複雑な挙動をするため、手動で実装するのは大変な作業です。
そこで、最近話題のChatGPTにLinuxコマンドエミュレータのソースコードを書いてもらい、実装の効率化を試みました。

実装したコードはGithubに公開しています。

こちらは実際に作成したコマンドエミュレータをハニーポットとして活用する例です。

仕様の検討

はじめに全体のざっくりとした仕様を考えます。

  • パイプラインやリダイレクトの解析とコマンドを実行するインタプリタ
  • Linuxコマンドを模擬する関数
  • pytestで問題がなければパッケージとdockerイメージを配布するAction

複数のコマンドがパイプラインで接続されていることを想定し、それらのコマンドを正しく解釈できるインタプリタを実装するために、ChatGPTに以下のような仕様での実装をお願いしてみました。

ChatGPTに以下のような仕様を伝えます。
command = "func1 arg1 arg2 | func2 arg1 < input.txt| func3 arg1 > hoge.txt"
上記のようなコマンド文字列を入力すると以下のような定義済みのpython関数が実行されるようなインタプリタを作成してください。
cmd_から始まる定義済み関数の第一引数は標準入力を意味します。cmd_から始まる戻り値は標準出力を意味します。
コマンドの>は記号の前方の関数の戻り値を後方に定義された名前のファイルへ書き込みすることを意味します。

r1 = cmd_func1("","arg1 arg2")
in = read_file("input.txt") // input.txtの内容を読み込む
r2 = cmd_func2(r1+in,"arg1")
r3 = cmd_func3(r2,"arg1")
create_file("hoge.txt", r3) // r3の内容を"hoge.txt"に書き込む

cmd_から始まる定義済み関数は以下のように定義されます。

def cmd_func1(std_in, cmd_args):
    std_out = ""
    parser = optparse.OptionParser()
    (options, args) = parser.parse_args(shlex.split(cmd_args))
    # ユーザ定義の処理
    return std_out

コマンドの|はパイプを意味しており、前方に定義された関数の戻り値を後方の関数の第一引数に渡し、第二引数はスペースを含む任意の文字列になります。
例えばfunc1 arg1 arg2 arg3の場合は第一引数がパイプ記号の前方の関数の戻り値(前方に関数がなければ空の文字列)を入力しarg1 arg2 arg3は文字列"arg1 arg2 arg3"として解釈し、関数の第二引数として以下のように入力します。

command = "func1 arg1 arg2 arg3| func2 arg1 arg2"
res1 = "" # 前方の関数の戻り値
res2 = cmd_func(res1, "arg1 arg2 arg3")

コマンドの<は記号は以下のように後方のファイルの内容を、前方の関数の第一引数に入力します。
command = "func1 arg1 < hoge.txt"

また以下の場合はhoge.txt, fuga.txtのように左のファイルから順番に内容を結合してcmd_func1の第一引数に入力されます。

command = "func1 arg1 < hoge.txt < fuga.txt < foo.txt"

in1 = read_file("hoge.txt")
in2 = read_file("fuga.txt") 
in3 = read_file("foo.txt") 
in = in1 + in2 + in3 # hoge.txt, fuga.txt, foo.txt の内容(文字列)を結合
cmd_func1(in, "arg1")

長いですが上記の仕様をチャット画面に貼り付けします。

インタプリタ実装

完全なソースコード

import optparse
import shlex

def cmd_func1(std_in, cmd_args):
    std_out = ""
    # ユーザ定義の処理
    return std_out

def cmd_func2(std_in, cmd_args):
    std_out = ""
    # ユーザ定義の処理
    return std_out

def cmd_func3(std_in, cmd_args):
    std_out = ""
    # ユーザ定義の処理
    return std_out

def read_file(filename):
    with open(filename, "r") as f:
        return f.read()

def create_file(filename, contents):
    with open(filename, "w") as f:
        f.write(contents)

def run_command(command):
    # | で区切って、各コマンドを分割する
    commands = command.split("|")
    # 結果を格納する変数を初期化する
    result = ""
    for cmd in commands:
        # コマンドの文字列をスペースで分割する
        cmd_parts = cmd.split()
        # コマンド名を取得する
        cmd_name = cmd_parts[0]
        # コマンドの引数を取得する
        cmd_args = cmd_parts[1:]
        # < を含む場合は、それより左側のコマンドを実行する
        if "<" in cmd_args:
            # < の位置を取得する
            input_redirection_index = cmd_args.index("<")
            # < の左側のコマンドを実行する
            result = run_command(" ".join(cmd_parts[:input_redirection_index]))
            # < の右側のファイル名を取得する
            input_filename = cmd_args[input_redirection_index + 1]
            # ファイルから入力を読み込む
            std_in = read_file(input_filename)
        # > を含む場合は、それより右側のコマンドを実行する
        elif ">" in cmd_args:
            # > の位置を取得する
            output_redirection_index = cmd_args.index(">")
            # > の左側のコマンドを実行する
            result = run_command(" ".join(cmd_parts[:output_redirection_index]))
            # > の右側のファイル名を取得する
            output_filename = cmd_args[output_redirection_index + 1]
            # ファイルに出力を書き込む
            create_file(output_filename, result)
            return ""
        # それ以外の場合は、コマンドを実行する
        else:
            # 定義済みの関数を呼び出す
            function_name = "cmd_" + cmd_name
            result = globals()[function_name](result, " ".join(cmd_args))
    return result

以下に、run_command関数を使用してコマンドを実行する例を示します。

# コマンドを実行する
command = "func1 arg1 arg2 | func2 arg1 < input.txt| func3 arg1 > hoge.txt"
result = run_command(command)

少しずつ修正をいれる

上記をそのまま実行しても、想定通りに動かないので手直しします。また環境変数の展開が出来ないので、以下のように追加の関数を作成してもらいます。

完全なソースコード

次のような関数を作ることができます。

import os

def replace_env_variables(command: str) -> str:
    # 引数の文字列を改行で分割して、各行に対して処理を行う
    lines = command.split("\n")
    processed_lines = []
    for line in lines:
        # 各行内の文字列を空白文字で分割する
        words = line.split()
        processed_words = []
        for word in words:
            if word.startswith("$"):
                # 環境変数名を取り出す
                env_name = word[1:]
                # 環境変数を取得する
                env_value = os.environ.get(env_name, "")
                processed_words.append(env_value)
            else:
                processed_words.append(word)
        # 各行を再び結合する
        processed_lines.append(" ".join(processed_words))
    # 各行を改行で結合する
    return "\n".join(processed_lines)

この関数を使うことで、次のようなコマンドを実行できます。

command = replace_env_variables("ls $HOME")
print(run_command(command))

コマンドの実装

動作のエミュレーションを行うようなコマンドをpython関数で実装していきます。
以下のようにChatGPTへ伝えてみます。

このままではインタプリタから実行できないので、詳細な仕様を伝えます。
ついでに引数の順番の仕様変更を行っています。(cmd_ls(std_in, cmd_args)からcmd_ls(cmd_args, std_in)に修正)

import optparse
import shlex
import os

def cmd_ls(cmd_args="", std_in=""):
    std_out = ""
    parser = optparse.OptionParser()
    # Add options to the parser
    parser.add_option("-a", "--all", action="store_true", dest="all", default=False, help="show hidden files")
    parser.add_option("-l", "--long", action="store_true", dest="long", default=False, help="show file details in long format")
    # Parse the command line arguments
    (options, args) = parser.parse_args(shlex.split(cmd_args))
    # Check if a directory was specified
    if len(args) > 0:
        path = args[0]
    else:
        path = "."
    # Get a list of files and directories in the given path
    files = os.listdir(path)
    # Show hidden files if the -a/--all option was specified
    if not options.all:
        files = [f for f in files if not f.startswith(".")]
    # Print the names of the files and directories
    if options.long:
        # Show file details in long format
        for file in files:
            file_path = os.path.join(path, file)
            file_stats = os.stat(file_path)
            file_permissions = oct(file_stats.st_mode)[-3:]
            file_owner = file_stats.st_uid
            file_group = file_stats.st_gid
            file_size = file_stats.st_size
            file_modified = file_stats.st_mtime
            std_out += f"{file_permissions} {file_owner} {file_group} {file_size} {file_modified} {file}\n"
    else:
        # Show file names only
        for file in files:
            std_out += f"{file}\n"
    return std_out

# Test the 'cmd_ls' function
print(cmd_ls("-la /tmp"))

実行しても、実際のlsコマンドとは全然違うような出力なので、修正をお願いしてみます。

パーミッションの部分が数字で表示されるので以下のように修正を依頼します

修正を繰り返すとコードが長くなるので関数に分割してリファクタリングしていきます。

最終的に出力されたコード

import os
import optparse
import shlex
import pwd
import stat
import time
import grp
from datetime import datetime

def cmd_ls(cmd_args="", std_in=""):
    std_out = ""
    parser = optparse.OptionParser()
    parser.add_option("-l", "--long", action="store_true", default=False)
    parser.add_option("-a", "--all", action="store_true", default=False)
    parser.add_option("-R", "--recursive", action="store_true", default=False)
    (options, args) = parser.parse_args(shlex.split(cmd_args))
    path = "." if not args else args[0]

    if options.recursive:
        for root, dirs, files in os.walk(path):
            std_out += f"\n{root}:\n"
            if options.long:
                std_out += print_long_format(root, files)
            else:
                std_out += print_short_format(root, files, options.all)
    else:
        if not options.all:
            files = [f for f in os.listdir(path) if not f.startswith(".")]
        else:
            files = os.listdir(path)
        if options.long:
            std_out = print_long_format(path, files)
        else:
            std_out = print_short_format(path, files, options.all)
    return std_out

def print_short_format(path, files, show_all):
    std_out = ""
    for file in files:
        std_out += f"{file}  "
    std_out += "\n"
    return std_out

def print_long_format(path, files):
    std_out = ""
    for file in files:
        file_path = os.path.join(path, file)
        file_stats = os.lstat(file_path)
        file_permission = stat.filemode(file_stats.st_mode)
        file_link = file_stats.st_nlink
        try:
            file_owner = pwd.getpwuid(file_stats.st_uid).pw_name
        except KeyError:
            file_owner = file_stats.st_uid
        try:
            file_group = pwd.getpwuid(file_stats.st_gid).pw_name
        except KeyError:
            file_group = file_stats.st_gid

        file_size = file_stats.st_size
        file_modify_time = datetime.fromtimestamp(file_stats.st_mtime).strftime("%b %d %H:%M")
        is_link = False
        if os.path.islink(file_path):
            is_link = True
            file_size = os.readlink(file_path)
        std_out += f"{file_permission} {file_link} {file_owner}\t{file_group}\t{file_size:>{len(str(file_size))+5}}\t{file_modify_time} "
        if is_link:
            std_out += f"{file} -> {file_size}\n"
        else:
            std_out += f"{file}\n"
    return std_out

CI/CDの実装

自動生成しただけでは、動作の詳細をテストしたり仕様としてドキュメントを残すことが難しいためテストコードを生成します。
通常のテスト駆動開発はテストコードの作成(仕様の決定)してからドメインコードの実装となりますが、ChatGPTを使ってコードを書く場合はドメインコードからテストコードを自動生成したり、テストコードからドメインコードを生成したりすることができます。
注意点としては最終的に出力されたコードの品質管理は人間が行う必要があるため、CI/CDによって自動テストやデプロイの作業は自動化しても、出力されたテストコードは人間が確認しておかなければなりません。

テストコードやデプロイを行うためのパッケージ化にはプロジェクト全体の構造を知っている必要があるので以下のように伝えます。

下記を実行しようとするとfile1file2が存在しないのでエラーになります。

テストにファイルが必要な場合は事前に用意しておくように伝えます

このような手順を繰り返してpytest -vがエラーにならないように処理内容やテストコードを確認して修正していきます。
テストコードが動作することを確認したらGithub Actionを実装していきます。

README.mdの作成

今までのチャット履歴からfakeshellの仕様について詳細まで理解しているので、README.mdを自動で作成することができます。自動生成されたREADMEに動画や、Dockerイメージの実行方法を追記したら完成です。

最後までお付き合いいただきありがとうございました

この記事を読んで、少しでもお役に立てたなら幸いです。何か質問やご意見がありましたら、お気軽にご連絡ください。

GitHubで編集を提案

Discussion