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)
浙公网安备 33010602011771号