My AI pentest agent reported 23 root shells. It had actually popped zero.のカバー画像

auto_majicly

私は自律型ペネトレーションテストエージェントを構築している — LLMが実際の
ツール(nmap、masscan、hydra、Metasploit、searchsploit)をループで駆動し、ターゲットの偵察、 エクスプロイトの選択、実行、成功判定、次のステップへ移行する。以下はすべて、意図的に脆弱な Metasploitable VMを分離ラボで実行したものだ。ここに記載されているのは、あなたが所有していないシステムを攻撃する手法ではなく、AIエージェントに自らの行動を正直に報告させる方法についての話である。

最初の厳しい教訓はこれだった:LLMエージェントは、実際には達成していない成功を喜んで報告する。初期の実行の一つは美しいレポートを生成した — 「23ポート中23ポートが侵害され、すべてにroot権限」。実際の数はゼロだった。シェルは一つもなかった。エージェントは完全な侵害を幻覚し、それを自信たっぷりに書き上げていた。

この投稿は、その嘘から、実際に3つの本物のrootシェルを獲得し、正直に報告し、証明できないことは主張しないエンジンへの軌跡である。


なぜ嘘をついたのか:「成功」は文字列マッチだった

バリデータ — エクスプロイトが成功したかどうかを判定するコンポーネント — は、一見合理的だが壊滅的なことを行っていた:


python
# 元の「成功したか?」チェック
if "login:" in output or "shellcodes" in output.lower():
    return "confirmed"
2つの独立した失敗モードがこれを助長した:

searchsploitの出力。Exploit-DBを検索すると、ツールはヘッダーを表示する:Exploits: ... / Shellcodes: .... すべての検索 — 純粋な情報収集で、一切のエクスプロイトを実行していない — に「Shellcodes」という単語が含まれていた。そのため、エージェントが調べたすべてのポートが侵害済みとマークされた。
バナーテキスト。login: をエコーバックするサービスはすべてシェルとしてカウントされた。
結果は、100%形容詞で0%証拠のレポートだった。「確認済み。」「高信頼性」「侵害済み」。すべて文字列マッチで、どれもシェルではなかった。

修正:雰囲気ではなく証拠
ヒューリスティックをbreach_confirmed()に置き換えた — 出力に実際のコード実行の証明が含まれる場合にのみtrueを返す関数である:

_SHELL_EVIDENCE = (
    re.compile(r"uid=\d+\([a-z]+\).*gid=\d+"),   # id(1)の出力
    re.compile(r"root@[\w.-]+:[~/]"),            # rootプロンプト
    # ...本物のシェルが生成するマーカーであり、バナーが偽造できないもの
)

def breach_confirmed(output: str) -> bool:
    return any(p.search(output) for p in _SHELL_EVIDENCE)
vsftpd 2.3.4のバックドアを起動してidを実行するように設計されたエクスプロイトは、uid=0(root) gid=0(root)を生成する。それが侵害である。searchsploitのテーブルはそうではない。以前は23/23と主張していた同じ実行は、今ではこう言う:3件確認済み、20件正しく未確認。3件は本物だ。その正直さ — 「試みたが失敗した」と言う意思 — は、システム全体で最も重要な特性である。

なぜ何も起動できなかったのか:退屈な配管のバグ
エージェントが嘘をつかなくなると、実際に何も到達していなかったことが露呈した。原因は賢いものではなかった — スタックトレースを投げない地味で静かな種類のものだった:

1. モデルが決して呼び出せなかったツール。モデルはsearchsploitを {"query": "vsftpd"} で呼び出し続けていた。ツールのスキーマは {"keyword": "..."} を要求していた。MCPレイヤーはすべての呼び出しを不正な形式として実行前にハードリジェクトしていた — 1回の実行で20回、それぞれが0秒のサイレント非イベント。修正:keyword | query | search を受け入れ、厳格な必須項目を削除。

2. ANSIコードがモジュール名を汚染。msfconsoleは検索語をカラーエスケープコードでハイライトする:exploit/unix/\x1b[45mftp\x1b[0m/vsftpd_234。私のパーサーはこれらのバイトをモジュールパスの一部として忠実に取り込んだため、選択されたすべてのモジュールがロードに失敗した。Gated Metasploitの成功率が0%だった理由は、プレーンテキストのログでは見えなかった。修正:解析前にstrip_ansi()を実行。

3. バージョン文字列が製品名を装う。nmapはRPCサービスをバージョン優先で報告する:2 (RPC #100000)。私のフィンガープリントパーサーは2を製品名として取得し、「2」をMetasploitの検索語として渡していた。修正:先頭が数字のガードを追加し、バージョンが製品と誤認されないようにする。

4. 1つのタイムアウト定数による5分間のハング。call_model()は300秒のツールタイムアウトを再利用していたため、1回のハングしたローカルLLMリクエストがエンゲージメント全体を5分間停止させていた。修正:別途90秒のモデルタイムアウトを設ける。

5. サンドボックスが自身の出力を飲み込む。コード実行サンドボックスは、TimeoutExpiredを発生させた際にパーサーが期待するEXIT契約を出力しなかったため、スローのエクスプロイトが壁時計時間全体を消費した後に不透明な「No EXIT marker」として表示された。修正:タイムアウトをキャッチし、部分出力付きでクリーンなEXIT 124を発行し、デフォルトの壁時計時間を60秒に短縮。

これらのどれも興味深いものではない。しかし「動作する」と「何も実行しない」の違いだった。集合的に、エージェントを膝から下を潰しながらもログは問題なく見えていた。

なぜゴミを起動したのか:関連性 vs. 関連性
配管が修正され、エージェントがMetasploitに到達し始めると、すぐに不条理なことをした。それが選択したものを見てみよう:

ポート    サービス(フィンガープリント)   選択したモジュール ランク
23  Linux telnetd   exploit/linux/http/asuswrt_lan_rce  excellent
513 rlogin (login)  exploit/windows/misc/ais_esel_server_rce    excellent
5900    VNC exploit/linux/misc/igel_command_injection   excellent
telnetに対するルーターRCE。Linuxのrloginサービスに対するWindowsエクスプロイト。どれも「excellent」ランクで、どれも無意味。

理由:msfconsole search <term> はモジュールの説明に対して検索語をマッチさせ、ターゲットだけでなく。曖昧な1語のフィンガープリント「login」は、ログインに関する記述を含む任意のモジュールを引き込む — そしてMetasploitはエクスプロイトの信頼性でランク付けし、ターゲットとの関連性ではランク付けしない。そのため、不適切なクエリに対する最高ランクのマッチは、自信たっぷりに、正確に間違っている。

関連性ゲート
修正はランキングの前に実行されるフィルターである:本物のサービストークンがモジュールのパスに現れる場合にのみ候補を保持する — すべてにマッチする一般的なツリー単語を破棄した後。

_GENERIC = {"linux", "windows", "unix", "multi", "http", "misc",
            "scanner", "auxiliary", "exploit", ...}

def _relevance_tokens(terms: str) -> set:
    return {w for w in terms.lower().split()
            if len(w) >= 3 and not w[0].isdigit() and w not in _GENERIC}

def _is_relevant(module: str, tokens: set) -> bool:
    return any(tok in module for tok in tokens)   # トークンはPATH内に存在する必要がある
asuswrt、ais_esel、igel — すべて削除された。X11用のx11_keyboard_execとVNC用のvnc_keyboard_execは残る。少しの再現性を多くの精度と交換する。これは、失敗モードが間違ったターゲットにエクスプロイトを起動することである場合の正しいトレードオフである。

ゲートが明らかにした quieter なバグ
作業中に気づいた:r-servicesモジュール(rsh/rlogin/rexec)はexploit/ではなくauxiliary/scanner/rservices/の下に存在する。私のセレクターにはexploits_only=Trueフラグがあり、補助ツリー全体をサイレントに破棄していた — そのため、rloginの正しいモジュールはどのターゲットでも選択され得なかった。フラグを削除し、階層ソートに切り替えた:exploit/モジュールを最初に(シェルを起動する)、補助ログインをランク付きフォールバックとして。ポートごとのハードコーディングはなし — Metasploitがカバーする任意のサービスに一般化する。

マルチエージェントへ — 嘘を再インポートせずに
次のフェーズは、単一エージェントのループを適切なパイプラインに変換することだった:
orchestrator → recon → attacker → validator → reporter。罠:それら6つのエージェントはすでに切断されたデモとして存在しており、古いバリデータは「23/23」を生成したのと全く同じ文字列ヒューリスティックを使用していた。それらをそのまま配線すると、元の罪を再インポートすることになる。

そこでルールはこうなった:1つの正直なエンジンを全員で共有する。私は証明済みのロジック — AgentMemory、plan_exploit_step、breach_confirmed — を単一のexploitation_core.pyに抽出した。マルチエージェントバリデータは今や文字列マッチではなくbreach_confirmedに委譲する。アタッカーは注入された、人間がゲートしたexecute_fnを通じてのみ実行する — オペレーターの承認ゲートをバイパスする非ゲートMCPクライアントではない。その不変条件は、バイパスパスが触れられた場合に失敗するテストによって固定されている:

def test_attacker_never_uses_ungated_path(monkeypatch):
    monkeypatch.setattr(mcp_client, "call_tool",
                        _boom)  # 呼び出された場合に例外を発生
    asyncio.run(run_attacker_gated(..., execute_fn=fake_gate))
    # すべてのエクスプロイトがゲートを通過した場合のみ緑
人間をループに留めることは交渉不可能である:エクスプロイトは2フェーズのオペレーター承認であり、スコープゲートは認可されたターゲット外のものをすべて拒否する。エージェントは選択については自律的だが、起動については自律的ではない。

午後を食いつぶした1文字のバグ
最後のものは、私のお気に入りだ。なぜなら非常に馬鹿げていて、よく隠れていたからだ。オーケストレーションされたパイプラインは起動時に拒否され続けていた:

🎯 engage multi 10.0.0.5
🚫 multi 10.0.0.5 refused at engagement start
コマンドはengage multi <target>(スペース)だった。ディスパッチャーはengage-multi(ハイフン)のみにマッチしていた。そのためengage multi 10.0.0.5は単一エージェントのengageブランチにフォールスルーし、「engage "を剥ぎ取り、ターゲットをリテラル文字列"multi 10.0.0.5"として残した — スコープゲートは完全に正しくこれを拒否した。「multi」という単語はターゲットの中にずっと乗っていた。ログにあったのに、私は何十回も読み飛ばしていた。

修正は、両方のセパレーターを受け入れる退屈な純粋関数と、回帰を防ぐ8つのテストである:

def parse_engagement_command(goal: str):
    s, low = goal.strip(), goal.strip().lower()
    for prefix in ("engage-multi ", "engage multi "):
        if low.startswith(prefix):
            return "multi", s[len(prefix):].strip()
    if low.startswith("engage "):
        return "single", s[len("engage "):].strip()
    return None, s
これにより、engage multiはようやくオーケストレーションされたエンジンにエンドツーエンドで入った:
recon → attack → validate → report、3つの本物のrootシェル(vsftpd、ingreslock、UnrealIRCd — すべてuid=0)、偽物ゼロ、そして関連性ゲートが機能(X11→x11、VNC→vnc、telnetにルーターRCEなし)。

エージェントに何かをやらせる人なら誰にでも言いたいこと
成功を形容詞ではなく成果物で測れ。「確認済み」はLLMが喜んで出力する単語だ。uid=0(root)は事実だ。成功チェックを事実から構築し、満たしにくくせよ。

あなたのエージェントは、証拠が禁じない限り、あなたが望む結果を報告する。偽陽性はプロンプトで取り除けるモデルの癖ではなく、エンジニアリングで対抗すべきシステム特性である。

退屈なバグが最もコストがかかる。間違ったパラメータ名、ANSIエスケープコード、共有タイムアウト定数 — どれもあなたが見ている場所でエラーを投げず、集合的に「動作する」エージェントを完全に不活性にできる。

人間をトリガーに留めよ。ターゲット選択における自律性は有用だ。実際のエクスプロイトのトリガーを引くことにおける自律性は責任だ。ゲートし、すべての決定をログに記録し、ゲートを迂回するルーティングがあればテストを失敗させよ。

TDDは、私が嘘をつくエンジンを正直なものにリファクタリングし、すでに動作していた部分を失わずに済んだ理由だ — 240以上のテスト、すべての変更でred-green、そして偽陽性クラスは回帰テストによって永遠に隔離された。

エージェントはまだ「完成」していない — オーケストレーションされたアタッカーは、いくつかのサービスで単一エージェントのループより少し徹底性が低く、r-servicesが実際に起動する前にモジュールオプションの調整が残っている。しかし、もう私に嘘をつかず、「23 of 23」の後、それはその機能の中で最も重要なものだ。

分離された自己所有のMetasploitableラボに対してのみ構築・テストされた。
このようなものを構築するなら、あなたも所有するラボに留めておけ。

---
## 2 — Hacker News版(短く、リンク優先)
> **形式:** HNは**タイトル** + **url** + オプションの短いテキストを望む。提出タイトルとしてタイトル行を使用し、URLフィールドにGitHub(または記事)リンクを入れ、最初のコメントとして本文を貼り付ける — HN読者は著者の短い「これがストーリーです」コメントに良く反応する。
Title: Show HN: AIペンテストエージェントにハッキングした内容について嘘をつくのをやめさせた
URL: https://github.com/XenoCoreGiger31/GEMMA-by-GOOGLE

**最初のコメント本文:**
私は自律型ペンテストエージェントを構築している — LLMが実際のツール(nmap、
hydra、Metasploit、searchsploit)をrecon → exploit → verifyのループで駆動し、分離ラボの
Metasploitable VMに対して実行する。

最初の厳しい教訓はエクスプロイトとは何の関係もなく、正直さに関するものだった:初期の実行の一つは「23ポート中23ポートが侵害され、すべてにroot権限」と主張する自信たっぷりのレポートを生成した。実際の数はゼロだった。シェルは一つもなかった。

原因は「成功」が文字列マッチだったことだ。バリデータは出力にlogin:またはshellcodesが含まれていればポートを侵害済みとマークした — searchsploitはすべての検索ヘッダーに「Shellcodes:」と出力するため、単にポートを見るだけで所有済みとフラグが立った。私はそれを、実際のコード実行の証明(uid=0(root)、rootプロンプト)でのみ通過する証拠チェックに置き換えた。同じ実行は次に正直に3件確認済み、20件正しく未確認と報告した。3件は本物だった。

次に、正直なエンジンは、非常に退屈な理由で実際に到達していなかったことを露呈した:モデルが決して送信しなかった必須パラメータ名(すべてのsearchsploit呼び出しが実行前にハードリジェクトされた);msfconsoleからのANSIカラーコードが解析されたすべてのモジュールパスを汚染(100%「ロードに失敗」);nmapのバージョン文字列が製品名として解析された;共有の300秒タイムアウトが1回のハングしたLLM呼び出しを5分間停止させた。

そして関連性の問題:msfconsole searchはモジュールの説明にマッチするため、曖昧な1語のフィンガープリントが「excellent」ランクのasuswrtルーターRCEをtelnetポートに起動し、LinuxのrloginサービスにWindowsエクスプロイトを起動した。修正は、本物のサービストークンがモジュールのパスに現れる場合にのみ候補を保持する。

各修正のコードを含む完全な記事はリポジトリにある。自己所有のラボに対してのみ構築・テスト;エクスプロイトは人間がゲートする。偽陽性クラスについて話すのを楽しみにしている — これは自身のアクションを報告する任意のエージェントに一般化すると思う。

フルスクリーンモードに入る フルスクリーンモードを終了