FlashFXP 连接 SSH 时提示“协商认证模式失败”,常见原因在于**协议与认证方式不兼容**。

FlashFXP 是一款老牌 FTP/SFTP 客户端,但其 SFTP 实现基于较旧的 SSH-2 协议栈(如 libssh),不支持现代 OpenSSH 服务端默认启用的新特性:如 `ecdsa-sha2-nistp256`/`ed25519` 主机密钥算法、`sk-*`(FIDO/U2F)密钥、或禁用的 `ssh-rsa`(SHA-1)签名(OpenSSH 8.8+ 默认弃用)。

 

 

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
FlashFXP 5.4.0.3970 —— rsa-sha2-256 主机密钥补丁(Python 版)

目标服务器  (OpenSSH 8.8) 只提供
rsa-sha2-512 / rsa-sha2-256 / ssh-ed25519 三种主机密钥算法;
FlashFXP 5.4 (SecureBlackbox) 只支持 ssh-rsa / ssh-dss / ecdsa-* /
x509v3-* / spki-* / pgp-*,主机密钥算法交集为空 → 报
"协商密钥交换算法失败"(KEX/加密/MAC 都能协商成功)。

本补丁复用 FlashFXP 内部"从未被实现"的 x509v3-sign-rsa 名字槽,
让客户端声明并解析 rsa-sha2-256(RSA over SHA-256),
把 RSA 主机密钥验签的哈希从 SHA-1 改为 SHA-256,
并放行 RFC 8332 的 rsa-sha2-256 签名 blob 名。

补丁点(FlashFXP.exe 文件偏移,均已交叉引用核实为唯一引用):
  P1  0x2B0998  字符串 "x509v3-sign-rsa" -> "rsa-sha2-256"(16 字节槽位足够)
  P2  0x2B0994  该 AnsiString 长度字段 15 -> 12
                (Delphi 常量串布局 = [refcount=FFFFFFFF][length][text][NUL],
                  长度字段在文本之前,别改错成 0x2B09AC —— 那是下一个串
                  x509v3-sign-dss 的长度)
  P3  0x6CC3DC  主机密钥算法名表 slot1 指针 0x006B1588 -> 0x006B1598
                (客户端 KEXINIT 中声明 rsa-sha2-256)
  P4  0x2F3169  主机密钥 blob 解析器字面量 0x006F40BC -> 0x006B1598
                —— 这是错误补丁,已移除:RFC 8332 §3.1 规定协商成
                rsa-sha2-256/512 时,主机公钥 blob(K_S)名字仍须为 "ssh-rsa"。
  P5  0x30F27C  RSA 验签哈希选择 SHA-1(0x7101) -> SHA-256(0x7104)
  P6  0x30F9E8  同上(另一个同类函数,需 --include-h2 才打;先不加)
  P9  0x306434  RSA 验签处放行签名算法名(VA 0x707034)
                原先:密钥为 RSA([ctx+0x3b0]==1) 时,要求签名 blob 名 == "ssh-rsa",
                      否则报 103;RFC 8332 下签名名为 "rsa-sha2-256/512" → 被拒。
                改法:把 "!=ssh-rsa 就报错" 的条件跳转 nop 掉(74 1E -> 90 90),
                      RSA 密钥不再校验签名名字符串。验签哈希仍由 code 参数
                      (0x7104=SHA-256) 决定(0x70711C 处 sub eax,0x7104),
                      故安全性不受影响;ssh-rsa(SHA-1) 旧服务器仍可正常连接。

会破坏 exe 尾部的 Authenticode 签名(data dir[4]),不影响运行。
回滚:--revert,或直接用 .bak-<hash> 备份覆盖。

用法:
    python patch-rsasha256.py --status
    python patch-rsasha256.py --apply
    python patch-rsasha256.py --revert
    python patch-rsasha256.py --apply --include-h2   # 若 --apply 后仍失败再试
"""

from __future__ import annotations

import argparse
import hashlib
import os
import shutil
import sys
from datetime import datetime

BASELINE_SHA256 = "52b79a8bdb5d8046821fe94986edc40e76d096cfca67d7b9b16846a43dbfbc7c"
BASELINE_SIZE = 8948032

# name, file-offset, old-bytes(hex), new-bytes(hex), description
PATCHES = [
    ("P1 名表字符串", 0x2B0998,
     "7835303976332D7369676E2D72736100", "7273612D736861322D32353600000000",
     '"x509v3-sign-rsa" → "rsa-sha2-256"'),
    ("P2 字符串长度", 0x2B0994,
     "0F000000", "0C000000",
     "15 → 12(AnsiString: [refcount][len][text][NUL],len 在文本前)"),
    ("P3 名表指针", 0x6CC3DC,
     "88156B00", "98156B00",
     "slot1 ssh-rsa → rsa-sha2-256(客户端声明 rsa-sha2-256)"),
    ("P5 验签哈希", 0x30F27C,
     "01", "04",
     "RSA 主机密钥验签 SHA-1 → SHA-256"),
    ("P9 签名名放行", 0x306434,
     "741E", "9090",
     'RSA 验签不再要求签名 blob 名=="ssh-rsa"(放行 rsa-sha2-256/512;哈希仍由 code=0x7104 决定)'),
]

PATCH_H2 = (
    "P6 验签哈希#2", 0x30F9E8, "01", "04",
    "同类函数2(--include-h2 时应用;先别用)",
)


def get_bytes_at(path: str, offset: int, count: int) -> bytes:
    with open(path, "rb") as f:
        f.seek(offset)
        data = f.read(count)
    if len(data) != count:
        raise RuntimeError(
            f"读取 {path} 偏移 0x{offset:X} 处 {count} 字节失败(只读到 {len(data)})"
        )
    return data


def file_sha256(path: str) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()


def format_bytes(b: bytes) -> str:
    return " ".join(f"{x:02X}" for x in b)


def classify(patches, path):
    """返回每个补丁点的状态行。"""
    rows = []
    for name, off, old_hex, new_hex, desc in patches:
        old = bytes.fromhex(old_hex)
        new = bytes.fromhex(new_hex)
        if len(old) != len(new):
            raise RuntimeError(f"补丁 {name} 新旧字节长度不一致")
        cur = get_bytes_at(path, off, len(new))
        state = "字节不符"
        if cur == new:
            state = "已应用"
        elif cur == old:
            state = "未应用"
        rows.append(
            {
                "name": name,
                "off": off,
                "cur": format_bytes(cur),
                "old": format_bytes(old),
                "new": format_bytes(new),
                "state": state,
                "desc": desc,
                "_old": old,
                "_new": new,
            }
        )
    return rows


def print_status_table(rows):
    w = max(len(r["name"]) for r in rows)
    print("")
    print(f"{'补丁'.ljust(w)}  偏移        状态    说明")
    print("-" * (w + 40))
    for r in rows:
        print(f"{r['name'].ljust(w)}  0x{r['off']:07X}  {r['state']:<6}  {r['desc']}")
    print("")


def check_not_running():
    """进程占用检测(Windows)。运行中的 exe 不能被写入。"""
    if os.name != "nt":
        return
    try:
        import subprocess
        out = subprocess.run(
            ["tasklist", "/FI", "IMAGENAME eq FlashFXP.exe", "/NH"],
            capture_output=True, text=True,
        ).stdout
        if "FlashFXP.exe" in out:
            raise RuntimeError("FlashFXP.exe 正在运行,请先退出再打补丁。")
    except FileNotFoundError:
        pass  # 没有 tasklist 就跳过


def newest_backup(exe_path: str):
    d = os.path.dirname(os.path.abspath(exe_path))
    base = os.path.basename(exe_path)
    cands = [
        os.path.join(d, fn)
        for fn in os.listdir(d)
        if fn.startswith(base + ".bak-")
    ]
    if not cands:
        return None
    return max(cands, key=os.path.getmtime)


def main(argv=None):
    p = argparse.ArgumentParser(
        description="FlashFXP 5.4.0.3970 rsa-sha2-256 主机密钥补丁",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p.add_argument("--exe", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "FlashFXP.exe"),
                   help="FlashFXP.exe 路径(默认脚本同目录)")
    p.add_argument("--status", action="store_true", help="查看状态(默认动作)")
    p.add_argument("--apply", action="store_true", help="打补丁(自动备份)")
    p.add_argument("--revert", action="store_true", help="回滚")
    p.add_argument("--include-h2", action="store_true", help="额外打 P6(同类函数2)")
    p.add_argument("--force", action="store_true", help="字节与预期不符时仍继续")
    args = p.parse_args(argv)

    exe = os.path.abspath(args.exe)
    if not os.path.isfile(exe):
        print(f"找不到文件: {exe}", file=sys.stderr)
        return 2

    print("")
    print("FlashFXP rsa-sha2-256 补丁")
    print(f"文件: {exe}")

    size = os.path.getsize(exe)
    sha = file_sha256(exe)
    print(f"大小: {size}   SHA256: {sha}")
    if size != BASELINE_SIZE:
        print("[警告] 文件大小与基线不符(可能不是原版或被改过)")
    if sha != BASELINE_SHA256:
        print("[警告] SHA256 与原始基线不符 —— 该文件已被改动过(若是本补丁自己改的则正常)")

    active = list(PATCHES)
    if args.include_h2:
        active.append(PATCH_H2)

    rows = classify(active, exe)

    bad = [r for r in rows if r["state"] == "字节不符"]
    if bad and not args.force:
        print_status_table(rows)
        print(f"[错误] 有 {len(bad)} 处补丁点字节与预期不符,已中止。"
              f"若确认该 exe 版本不同,请加 --force。", file=sys.stderr)
        return 3

    applied = [r for r in rows if r["state"] == "已应用"]
    todo = [r for r in rows if r["state"] != "已应用"]

    # ---------------- 状态(默认)
    if args.status or (not args.apply and not args.revert):
        print_status_table(rows)
        tag = "(含 P6)" if args.include_h2 else ""
        print(f"状态: {len(applied)} 已应用 / {len(todo)} 待应用{tag}")
        if applied and not todo:
            print("完整补丁已在位。")
        elif applied:
            print("部分补丁在位 —— 建议先 --revert 再 --apply 保持一致。")
        print("")
        print("用法:")
        print("  python patch-rsasha256.py --status              查看状态")
        print("  python patch-rsasha256.py --apply                打补丁(自动备份)")
        print("  python patch-rsasha256.py --revert               回滚")
        print("  python patch-rsasha256.py --apply --include-h2   额外打 P6(P5失败后再试)")
        return 0

    check_not_running()

    # ---------------- 回滚
    if args.revert:
        bak = newest_backup(exe)
        if bak:
            print(f"发现备份: {bak}")
            shutil.copyfile(bak, exe)
            print("已从备份还原。")
        elif applied:
            with open(exe, "r+b") as f:
                for r in active:
                    f.seek(r["off"])
                    f.write(r["_old"])
            print("未找到备份,已按原值写回。")
        else:
            print("当前未应用任何补丁,无需回滚。")
            return 0
        newsha = file_sha256(exe)
        print(f"还原后 SHA256: {newsha}")
        if newsha == BASELINE_SHA256:
            print("与原始基线一致 ✔")
        return 0

    # ---------------- 应用
    if args.apply:
        if not todo:
            print("补丁均已应用,无需改动。")
            return 0

        parent = os.path.dirname(exe)
        backup = os.path.join(parent, f"{os.path.basename(exe)}.bak-{sha[:12]}")
        if not os.path.exists(backup):
            shutil.copyfile(exe, backup)
            print(f"已备份 -> {backup}")

        try:
            with open(exe, "r+b") as f:
                for r in todo:
                    f.seek(r["off"])
                    f.write(r["_new"])
                f.flush()
                os.fsync(f.fileno())
        except Exception:
            print("[警告] 写入失败,正在从备份恢复...")
            shutil.copyfile(backup, exe)
            raise

        # 回读校验
        print("")
        fail = []
        for r in active:
            cur = get_bytes_at(exe, r["off"], len(r["_new"]))
            ok = cur == r["_new"]
            if not ok:
                fail.append(r["name"])
            print(f"{r['name']:<16} 0x{r['off']:07X} 写入 {format_bytes(r['_new'])} "
                  f"回读 {format_bytes(cur)} {'OK' if ok else '失败'}")

        if fail:
            print("[警告] 回读校验失败,正在从备份恢复...")
            shutil.copyfile(backup, exe)
            print(f"[错误] 写入校验失败: {', '.join(fail)}", file=sys.stderr)
            return 4

        print("")
        print(f"补丁完成。新 SHA256: {file_sha256(exe)}")
        print("")
        print("下一步(验证):")
        print("  1. 运行 verify\\run-mock.cmd            (或 -profile ecdsa 做回归)")
        print("  2. FlashFXP 新建站点: SFTP / 127.0.0.1 / 端口 2222 / 任意用户名密码")
        print("  3. 连接。看到 mock 输出\"主机密钥协商成功\"即为补丁生效。")
        print("  4. 失败时: python patch-rsasha256.py --revert 回滚")
        return 0

    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except RuntimeError as e:
        print(f"[错误] {e}", file=sys.stderr)
        sys.exit(1)
View Code