[Python] 纯文本查看 复制代码
#!/usr/bin/env python3
"""
Sublime Text 4 (Build 4206) license patcher
- 生成随机 RSA1024 e=17 密钥对
- patch exe: 公钥替换, 验签NOP, 弹窗NOP, 状态强制, 标题清理
- 生成 license 文件
用法: python patch_sublime.py [sublime_text.exe 路径]
"""
import os, sys, shutil, subprocess, re
# ============ 配置 (Build 4206, x64, file size 7918528) ============
BUILD = 4206
# --- 公钥 ---
PUBKEY_OFFSET = 0x6BD312 # 320 hex ASCII chars (160 bytes DER)
PUBKEY_LEN = 320
# --- 验签核心函数 ---
# 0x4E8A37: 原 push r15;push r14;push r13;push r12;push rsi (41 57 41 56 41 55 41 54 56)
# patch: mov rax,[rsp+0x30]; mov byte [rax],1; ret
# 效果: 第6参数(结果指针) *result = 1 (registered), 立即返回
VERIFY_FUNC_OFFSET = 0x4E8A37
ORIG_VERIFY_HEAD = bytes.fromhex("415741564155415456")
VERIFY_FUNC_PATCH = bytes.fromhex("488b442430c60001c3")
# --- is_registered 函数 (0xFCF7A) ---
# 原: mov rax,[rcx+8]; cmp byte[rax+8],1; jnz+4; mov al,[rax+10]; ret; xor eax,eax; ret
# patch: mov al,1; ret + nop padding (总是返回 1 = registered)
IS_REGISTERED_OFFSET = 0xFCF7A
ORIG_IS_REGISTERED = bytes.fromhex("488b41088078080175048a4010c331c0c3") # 17 bytes
IS_REGISTERED_PATCH = bytes.fromhex("b001c3" + "90" * 14) # mov al,1; ret + 14 nops
# --- is_unregistered 函数 (0xFCF60) ---
# 原: mov al,[rax+8]; xor al,1; ret (返回 !state)
# patch: xor al,al; ret (总是返回 0 = NOT unregistered)
IS_UNREGISTERED_OFFSET = 0xFCF60
ORIG_IS_UNREGISTERED = bytes.fromhex("8a40083401c3")
IS_UNREGISTERED_PATCH = bytes.fromhex("30c0c3909090") # xor al,al; ret; nop*3
# --- 启动路径: 第2个 JNZ (Enter License vs registered path) ---
# 0x31A93: 75 0A (jnz +0xa) -> 90 90 (NOP, 强制 registered path)
DIALOG_JNZ_OFFSET = 0x31A93
ORIG_DIALOG_JNZ = bytes.fromhex("750a")
DIALOG_JNZ_PATCH = bytes.fromhex("9090")
# --- 窗口创建函数内部状态检查 ---
# 0xF73FF: 0F 84 EC 01 00 00 (jz +0x1EC, 跳过窗口创建) -> NOP*6
WNDCREATE_JZ_OFFSET = 0xF73FF
ORIG_WNDCREATE_JZ = bytes.fromhex("0f84ec010000")
WNDCREATE_JZ_PATCH = bytes.fromhex("909090909090")
# --- 标题 "(UNREGISTERED)" 清除 ---
# XOR-encoded "UNREGISTERED" decoded char-by-char in title function
# 跳过解码循环: 0x1E120E: 74 26 (jz +0x26) -> EB 26 (jmp +0x26, 无条件跳过)
TITLE_LOOP_OFFSET = 0x1E120E
ORIG_TITLE_LOOP = bytes.fromhex("7426")
TITLE_LOOP_PATCH = bytes.fromhex("eb26") # jmp (always skip decode loop)
# 跳过 " (" 前缀: 0x1E116D flag test jz
# 原: 40 f6 c7 3e; 74 11 -> 74 11 改 EB 11 (jmp, 总是跳过)
TITLE_PAREN_OPEN_OFFSET = 0x1E1171 # the JZ byte
ORIG_TITLE_PAREN_OPEN = bytes.fromhex("7411")
TITLE_PAREN_OPEN_PATCH = bytes.fromhex("eb11")
# 跳过 ")" 后缀: 0x1E1236 flag test jz
TITLE_PAREN_CLOSE_OFFSET = 0x1E123A # the JZ byte
ORIG_TITLE_PAREN_CLOSE = bytes.fromhex("7411")
TITLE_PAREN_CLOSE_PATCH = bytes.fromhex("eb11")
# --- About 对话框: 强制 "Registered to" 路径 ---
# 0x30008: 75 4A (JNZ, state!=1 跳到 Unregistered) -> 90 90 (NOP, 强制走 Registered 路径)
ABOUT_JNZ_OFFSET = 0x30008
ORIG_ABOUT_JNZ = bytes.fromhex("754a")
ABOUT_JNZ_PATCH = bytes.fromhex("9090")
# --- About 对话框: "Registered to " -> 自定义文本 ---
# 该文本是一个 [begin, end) 字符串区间, 由两条 LEA 分别加载:
# 0x30022: lea rax,[rip+disp] disp32 @ 0x30025, next_rip(file)=0x30029 -> begin
# 0x30034: lea rax,[rip+disp] disp32 @ 0x30037, next_rip(file)=0x3003B -> end
# 原始: begin=0x6C2668 ("Registered to "), end=0x6C2676 (=begin+14)
#
# 注意: 只改 begin 会让 begin==end 变成空串, 整行会被布局直接跳过(不渲染),
# 所以 begin 和 end 必须同时重定向。license 文件里的 name 不参与这一行 ——
# 验签函数被 stub 后 license 对象的 name 字段始终为空, 从来渲染不出来。
ABOUT_SPAN_BEGIN_DISP_OFFSET = 0x30025 # begin 的 disp32
ABOUT_SPAN_END_DISP_OFFSET = 0x30037 # end 的 disp32
ORIG_ABOUT_SPAN_BEGIN_DISP = bytes.fromhex("3f2a6900")
ORIG_ABOUT_SPAN_END_DISP = bytes.fromhex("3b2a6900")
# disp = 目标文件偏移 - 指令下一条的文件偏移 + RVA_DELTA
ABOUT_SPAN_BEGIN_NEXT_RIP = 0x30029
ABOUT_SPAN_END_NEXT_RIP = 0x3003B
RVA_DELTA = 0x400 # 该 section 内 RVA 与文件偏移之差
# About 自定义文本存放的 .data cave (紧跟搬迁后的 "%d User License")
ABOUT_TEXT_CAVE_OFFSET = 0x6E91F5
ABOUT_TEXT_CAVE_LIMIT = 64 # 该 cave 有 128 字节全 0, 留一半足够
# --- About 窗口高度: 文本行数 ---
# 0x2FF04: BD 04 00 00 00 (mov ebp, 4) -> 立即数 @ 0x2FF05
# 该处早于 0x30008 的状态判断, 且我们没有改写 [rbx+8] 的原始状态字节,
# 所以实际走的是 ebp=4 这条路径。多渲染一行 (自定义文本) 后窗口会矮一行,
# 最后一行 "Dev Channel, Build ####" 被裁掉 -> 行数改成 5。
ABOUT_LINES_IMM_OFFSET = 0x2FF05
ORIG_ABOUT_LINES = 4
ABOUT_LINES = 5
# --- About 对话框: "Unlimited User License" -> 自定义文本 ---
# 直接覆写 .rdata 的字符串 (file 0x6AF930), 27+1 字节会覆盖相邻的 "%d User License"
# 所以把 "%d User License" 搬到 .data cave, 重定向其 LEA
ABOUT_STR_OFFSET = 0x6AF930 # "Unlimited User License" 原位置
ABOUT_STR_ORIG_LEN = 22 # 原字符串长度 (不含 null)
# "%d User License" 搬迁: 原在 0x6AF947, LEA at 0x4DED65 (4C 8D 05 XX XX XX XX)
DUSER_STR_CAVE_OFFSET = 0x6E91E5 # .data cave 放 "%d User License"
DUSER_LEA_DISP_OFFSET = 0x4DED68 # LEA disp32 位置
ORIG_DUSER_LEA_DISP = bytes.fromhex("db0f1d00")
DUSER_LEA_DISP_PATCH = bytes.fromhex("79a82000") # -> cave RVA 0x6EA1E5
# --- License 信息 ---
# ABOUT_TEXT: About 窗口里显示的第一行 (原 "Registered to " 的位置)
# 直接写进 exe 的 .data cave, 与 license 文件无关, 长度不受 14 字节限制,
# 任意 UTF-8 都可以 (含 U+30FB "・" 这类字符, 实测正常渲染)
ABOUT_TEXT = "Ciallo~(∠・ω< )⌒☆"
LICENSE_NAME = "Ciallo~(∠・ω< )⌒☆"
LICENSE_TYPE = "Unlimited User License"
LICENSE_PRODUCT = "EA7E-1000"
NOP5 = bytes.fromhex("9090909090")
NOP6 = bytes.fromhex("909090909090")
def find_exe():
"""找 sublime_text.exe"""
if len(sys.argv) >= 2:
return os.path.abspath(sys.argv[1])
candidates = [
r"D:\Program Files\Sublime Text 4\sublime_text.exe",
r"D:\tools\windows\Sublime Text\sublime_text.exe",
r"C:\Program Files\Sublime Text\sublime_text.exe",
r"C:\Program Files (x86)\Sublime Text\sublime_text.exe",
]
for c in candidates:
if os.path.exists(c):
return c
for base in [r"D:\Program Files", r"C:\Program Files", r"C:\Program Files (x86)"]:
if not os.path.exists(base):
continue
for root, dirs, files in os.walk(base):
if "sublime_text.exe" in files:
return os.path.join(root, "sublime_text.exe")
if root[len(base):].count(os.sep) >= 3:
dirs[:] = []
return None
def check_build(exe_path):
"""验证 build (优先用 .bak 原版)"""
check = exe_path + ".bak" if os.path.exists(exe_path + ".bak") else exe_path
try:
r = subprocess.run([check, "--version"], capture_output=True, text=True, timeout=10)
out = (r.stdout + r.stderr).strip()
if str(BUILD) not in out:
print(f"[!] 警告: 目标 build 不是 {BUILD}: {out}")
return False
print(f"[+] 确认 build {BUILD}: {out}")
return True
except Exception as e:
print(f"[!] 无法验证 build: {e}")
return False
def ensure_crypto():
"""确保 pycryptodome 已安装"""
try:
from Crypto.PublicKey import RSA
return True
except ImportError:
print("[*] 安装 pycryptodome...")
subprocess.run([sys.executable, "-m", "pip", "install", "pycryptodome"], check=True)
return True
def gen_keypair():
"""生成 RSA1024 e=17 密钥对, 公钥 DER 必须 160 bytes"""
from Crypto.PublicKey import RSA
print("[*] 生成 RSA1024 e=17 密钥对...")
for i in range(200):
key = RSA.generate(1024, e=17)
pub_der = key.publickey().export_key(format="DER", pkcs=8)
if len(pub_der) == 160:
print(f"[+] 密钥生成成功 (第 {i+1} 次)")
return key, pub_der
raise RuntimeError("200 次未找到 160 bytes 公钥")
def patch_exe(exe_path, pub_der):
"""patch exe: 公钥 + 验签 + 状态函数 + 启动路径 + 窗口创建 + 标题
流程: sublime_text.exe.bak = 原版, sublime_text.exe = 修改后
"""
bak = exe_path + ".bak"
# 备份原版 (仅首次)
if not os.path.exists(bak):
shutil.copy(exe_path, bak)
print(f"[+] 备份原版: {bak}")
else:
print(f"[*] 原版备份已存在: {bak}")
# 始终从原版读取
data = bytearray(open(bak, "rb").read())
# 验证文件大小
if len(data) != 7918528:
print(f"[!] 警告: 文件大小 {len(data)} != 7918528, 可能版本不对")
# 验证公钥偏移
orig_pub = data[PUBKEY_OFFSET:PUBKEY_OFFSET+PUBKEY_LEN].decode("ascii", errors="replace")
if not re.fullmatch(r'[0-9A-Fa-f]{320}', orig_pub):
raise RuntimeError(f"偏移 0x{PUBKEY_OFFSET:x} 处不是 320 hex 公钥")
# Patch 公钥
new_pub_hex = pub_der.hex().upper()
data[PUBKEY_OFFSET:PUBKEY_OFFSET+PUBKEY_LEN] = new_pub_hex.encode("ascii")
print(f"[+] 公钥替换: 0x{PUBKEY_OFFSET:x}")
# Patch 验签函数 -> set *result=1; ret
data[VERIFY_FUNC_OFFSET:VERIFY_FUNC_OFFSET+len(VERIFY_FUNC_PATCH)] = VERIFY_FUNC_PATCH
print(f"[+] 验签函数: 0x{VERIFY_FUNC_OFFSET:x} -> *result=1; ret")
# Patch is_registered -> always return 1
data[IS_REGISTERED_OFFSET:IS_REGISTERED_OFFSET+len(IS_REGISTERED_PATCH)] = IS_REGISTERED_PATCH
print(f"[+] is_registered: 0x{IS_REGISTERED_OFFSET:x} -> ret 1")
# Patch is_unregistered -> always return 0
data[IS_UNREGISTERED_OFFSET:IS_UNREGISTERED_OFFSET+len(IS_UNREGISTERED_PATCH)] = IS_UNREGISTERED_PATCH
print(f"[+] is_unregistered: 0x{IS_UNREGISTERED_OFFSET:x} -> ret 0")
# Patch 启动 JNZ -> NOP (force registered path)
data[DIALOG_JNZ_OFFSET:DIALOG_JNZ_OFFSET+2] = DIALOG_JNZ_PATCH
print(f"[+] 启动 JNZ->NOP: 0x{DIALOG_JNZ_OFFSET:x}")
# Patch 窗口创建 JZ -> NOP (force window creation)
data[WNDCREATE_JZ_OFFSET:WNDCREATE_JZ_OFFSET+6] = WNDCREATE_JZ_PATCH
print(f"[+] 窗口创建 JZ->NOP: 0x{WNDCREATE_JZ_OFFSET:x}")
# Patch 标题 UNREGISTERED 解码循环 -> jmp (skip)
data[TITLE_LOOP_OFFSET:TITLE_LOOP_OFFSET+2] = TITLE_LOOP_PATCH
print(f"[+] 标题循环 jz->jmp: 0x{TITLE_LOOP_OFFSET:x}")
# Patch 标题 "(" 前缀 -> jmp (skip)
data[TITLE_PAREN_OPEN_OFFSET:TITLE_PAREN_OPEN_OFFSET+2] = TITLE_PAREN_OPEN_PATCH
print(f"[+] 标题 '(' skip: 0x{TITLE_PAREN_OPEN_OFFSET:x}")
# Patch 标题 ")" 后缀 -> jmp (skip)
data[TITLE_PAREN_CLOSE_OFFSET:TITLE_PAREN_CLOSE_OFFSET+2] = TITLE_PAREN_CLOSE_PATCH
print(f"[+] 标题 ')' skip: 0x{TITLE_PAREN_CLOSE_OFFSET:x}")
# Patch About 对话框 JNZ -> NOP (强制 Registered 路径)
data[ABOUT_JNZ_OFFSET:ABOUT_JNZ_OFFSET+2] = ABOUT_JNZ_PATCH
print(f"[+] About JNZ->NOP: 0x{ABOUT_JNZ_OFFSET:x} (强制 Registered 路径)")
# Patch About 第一行文本: 写入 .data cave, begin/end 两条 LEA 同时重定向
text_bytes = ABOUT_TEXT.encode("utf-8")
if len(text_bytes) + 1 > ABOUT_TEXT_CAVE_LIMIT:
raise RuntimeError(f"ABOUT_TEXT 过长: {len(text_bytes)} > {ABOUT_TEXT_CAVE_LIMIT - 1}")
data[ABOUT_TEXT_CAVE_OFFSET:ABOUT_TEXT_CAVE_OFFSET+ABOUT_TEXT_CAVE_LIMIT] = b"\x00" * ABOUT_TEXT_CAVE_LIMIT
data[ABOUT_TEXT_CAVE_OFFSET:ABOUT_TEXT_CAVE_OFFSET+len(text_bytes)] = text_bytes
disp_begin = ABOUT_TEXT_CAVE_OFFSET - ABOUT_SPAN_BEGIN_NEXT_RIP + RVA_DELTA
disp_end = ABOUT_TEXT_CAVE_OFFSET + len(text_bytes) - ABOUT_SPAN_END_NEXT_RIP + RVA_DELTA
data[ABOUT_SPAN_BEGIN_DISP_OFFSET:ABOUT_SPAN_BEGIN_DISP_OFFSET+4] = disp_begin.to_bytes(4, "little")
data[ABOUT_SPAN_END_DISP_OFFSET:ABOUT_SPAN_END_DISP_OFFSET+4] = disp_end.to_bytes(4, "little")
print(f"[+] About 第一行: cave 0x{ABOUT_TEXT_CAVE_OFFSET:x} <- {ABOUT_TEXT!r} ({len(text_bytes)} bytes)")
print(f" span begin disp=0x{disp_begin:08x} @0x{ABOUT_SPAN_BEGIN_DISP_OFFSET:x}")
print(f" span end disp=0x{disp_end:08x} @0x{ABOUT_SPAN_END_DISP_OFFSET:x}")
# Patch About 窗口行数 (4 -> 5), 否则最后一行 "Dev Channel, Build ####" 被裁掉
if data[ABOUT_LINES_IMM_OFFSET] != ORIG_ABOUT_LINES:
print(f"[!] 警告: 0x{ABOUT_LINES_IMM_OFFSET:x} 原值不是 {ORIG_ABOUT_LINES}")
data[ABOUT_LINES_IMM_OFFSET] = ABOUT_LINES
print(f"[+] About 行数: 0x{ABOUT_LINES_IMM_OFFSET:x} {ORIG_ABOUT_LINES} -> {ABOUT_LINES} (修正窗口高度)")
# Patch About 第二行: 覆写 "Unlimited User License" + 搬迁相邻字符串
# 1) 把 "%d User License" 搬到 .data cave, 重定向其 LEA
duser_str = b"%d User License\x00"
data[DUSER_STR_CAVE_OFFSET:DUSER_STR_CAVE_OFFSET+len(duser_str)] = duser_str
data[DUSER_LEA_DISP_OFFSET:DUSER_LEA_DISP_OFFSET+4] = DUSER_LEA_DISP_PATCH
# 2) 覆写原位置 (0x6af930) 为我们的文本
about_bytes = LICENSE_TYPE.encode("utf-8") + b"\x00"
data[ABOUT_STR_OFFSET:ABOUT_STR_OFFSET+len(about_bytes)] = about_bytes
print(f"[+] About 文本: 0x{ABOUT_STR_OFFSET:x} <- {LICENSE_TYPE!r}")
# 写入: 修改后 -> sublime_text.exe (原版保留在 .bak)
open(exe_path, "wb").write(data)
print(f"[+] 写入修改后: {exe_path}")
def gen_license(priv_key):
"""生成 license 文件"""
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA1
data_to_sign = "\n".join([LICENSE_NAME, LICENSE_TYPE, LICENSE_PRODUCT]).encode("utf-8")
sig = pkcs1_15.new(priv_key).sign(SHA1.new(data_to_sign))
sig_hex = sig.hex()
sig_lines = [sig_hex[i:i+32] for i in range(0, len(sig_hex), 32)]
assert len(sig_lines) == 8, f"签名行数不对: {len(sig_lines)}"
lines = ["----- BEGIN LICENSE -----", LICENSE_NAME, LICENSE_TYPE, LICENSE_PRODUCT] + sig_lines + ["------ END LICENSE ------"]
return "\n".join(lines) + "\n"
def write_license(license_text, exe_path):
"""写 license 到 exe 同目录 Data/Local/ 和 APPDATA (UTF-8)"""
# 写到 exe 旁边的 Data/Local/
exe_dir = os.path.dirname(exe_path)
local_dir = os.path.join(exe_dir, "Data", "Local")
os.makedirs(local_dir, exist_ok=True)
lic_path = os.path.join(local_dir, "License.sublime_license")
with open(lic_path, "w", newline="\n", encoding="utf-8") as f:
f.write(license_text)
print(f"[+] license: {lic_path}")
# 也写到 APPDATA
appdata_dir = os.path.expandvars(r"%APPDATA%\Sublime Text\Local")
os.makedirs(appdata_dir, exist_ok=True)
appdata_lic = os.path.join(appdata_dir, "License.sublime_license")
with open(appdata_lic, "w", newline="\n", encoding="utf-8") as f:
f.write(license_text)
print(f"[+] license: {appdata_lic}")
def main():
print("=" * 60)
print(f"Sublime Text 4 (Build {BUILD}) Patcher")
print("=" * 60)
exe_path = find_exe()
if not exe_path:
print("[!] 未找到 sublime_text.exe")
print(f" python {sys.argv[0]} <path-to-sublime_text.exe>")
sys.exit(1)
print(f"[+] 目标: {exe_path}")
check_build(exe_path)
ensure_crypto()
priv_key, pub_der = gen_keypair()
try:
patch_exe(exe_path, pub_der)
except PermissionError:
print("[!] 文件被锁,请先关闭 Sublime Text")
sys.exit(1)
license_text = gen_license(priv_key)
print(f"\n[*] License:\n{license_text}")
write_license(license_text, exe_path)
print("\n" + "=" * 60)
print("[+] 完成!")
print(f" 原版: {exe_path}.bak")
print(f" 修改: {exe_path}")
print("=" * 60)
if __name__ == "__main__":
main()