服务器只放行握手时携带专属令牌的定制客户端,原版 Terraria 客户端在握手阶段被拒绝。
这是一个 TShock 服务端插件(TerrariaPlugin 子类),在握手阶段做客户端准入。握手时携带专属令牌的「服务端专用包」客户端放行,原版 Terraria 客户端一律被提醒并踢出。判定只看客户端自带的版本前缀,与玩家账号无关,实际效果等同于一份客户端指纹白名单。
| 场景 | 说明 |
|---|---|
| 私有服 / 小圈子服 | 只允许自家人用定制包进服,挡住随机扫端口的原版玩家 |
| 分流与公告 | 拒绝时下发自定义提示,可直接引导玩家去下载专用包 |
| 合规兜底 | 与账号白名单、设备绑定叠加,形成多层准入 |
配套的客户端侧(把令牌注入 APK 的补丁工具与重签名流程)不在本仓库范围内,见文末「配套组件」。
Terraria 客户端连接服务器时发的第一个包是 ConnectRequest(MessageID = 1)。经源码核实,它的 payload 只有一个 .NET BinaryWriter 字符串:
原版客户端 "Terraria" + <release号> 例:"Terraria326"
专用包客户端 "<令牌>" + <release号> 例:"TSC0DE1A326"
服务端在 MessageBuffer.GetData 的 case 1 里用 reader.ReadString() 读出该串,与本地 "Terraria" + Main.curRelease 做全等比较,不等则下发 MessageID.Disconnect。
包体里没有 UUID、玩家名或任何自由格式字段,所以「客户端身份」唯一可用的载体就是这串版本前缀。
移动版是 Unity IL2CPP 构建,C# 代码编译进 libil2cpp.so,字符串常量存放在 assets/bin/Data/Managed/Metadata/global-metadata.dat 的字符串字面量表中。
补丁工具按内容定位到唯一一个内容为 "Terraria" 的条目,等长替换为专属令牌:
元数据版本 : 31
字面量条目总数 : 18819
命中条目 : #12763 (dataIndex=0x57014)
原值 : b'Terraria'
新值 : b'TSC0DE1A'
必须等长替换,是因为 IL2CPP 的 stringLiteralData 是一个扁平字节串,各条目靠 (length, dataIndex) 定位。等长替换意味着 length 字段、blob 总长度、以及后续所有条目的 dataIndex 全部不变,结构零风险。因此令牌长度必须恰好 8 字节(与 "Terraria" 等长)。
插件在 ServerApi.Hooks.NetGetData 上以 int.MinValue 优先级注册,确保早于 TShock 自身的处理器拿到原始包:
HookManager.InvokeNetGetData 内部顺序:
1. InvokeServerConnect → TShock.OnConnect 在此创建 TSPlayer
2. NetGetData 集合触发 → 本插件在此(int.MinValue 排最前)
3. args.Handled == true → OTAPI 直接 return,跳过 vanilla 处理
判定与处置都在 DedicatedClientGuardPlugin.OnNetGetData 里:
ConnectRequest(id=1) 到达
│ Enabled=false → 完全不干预
▼
读取版本串(起点 = args.Index,自行解析 7-bit 变长长度前缀)
├─ 解析异常 / 空串 → REJECT-PARSEFAIL / REJECT-EMPTY 踢出
├─ 不可解析 → REJECT-UNPARSEABLE 踢出
├─ 令牌 = Terraria → REJECT-VANILLA 踢出「无专用包标识」
├─ 令牌不在白名单 → REJECT-TOKEN 踢出
├─ release 不匹配 → REJECT-RELEASE 踢出(仅 ExactWithRelease)
└─ 全部通过 → ACCEPT 接管握手放行
两种模式下判定的处置方式不同,解析与审计是同一套:
DryRun |
行为 |
|---|---|
false(默认,强制) |
命中拒绝则下发自定义提示并踢出;命中放行则接管握手 |
true(干跑观察) |
仍然完整解析并写审计日志,但一律不拦截、不接管(放行交回原版逻辑) |
干跑模式的用处是上线前统计「各客户端实际上报的版本串」,用来确认令牌是否写对。 注意干跑不接管握手,因此此时专用包反而会被原版的版本校验踢掉,这是预期行为。
专用包上报的是 TSC0DE1A326,原版的版本检查必然失败并把玩家踢掉。所以插件要自己完成原版本该做的事(AdoptHandshake):
Netplay.Clients[who].State = 1; // 推进状态 0 → 1
NetMessage.SendData((int)PacketTypes.ContinueConnecting, who, -1, // 手动下发 msg 3
NetworkText.FromLiteral(""), who);
args.Handled = true; // 阻止原版的版本检查后续流程(ClientUUID → PlayerInfo → ContinueConnecting2 …)完全交回原版与 TShock 处理。
服务器设置了 Terraria 密码时改发
PasswordRequired(msg 37)。 这与社区插件SkipVersionCheck采用的机制相同,区别在于它放宽版本检查,本插件收紧准入。
拒绝时同样要 Handled = true,否则原版会接着发它自己的 LegacyMultiplayer.4("You are not using the same version as this server."),玩家会看到两条互相矛盾的提示。
# 1) 自备 TShock 参考程序集(见 refs/README.md,勿入库)
# 下载 TShock 6.2.1 (Terraria 1.4.5.8) 发行包,解出:
# refs/tshock-6.2.1/lib/{TShockAPI,TerrariaServer,OTAPI,OTAPI.Runtime,ModFramework,HttpServer}.dll
# 2) 编译
dotnet build plugin/DedicatedClientGuard/DedicatedClientGuard.csproj -c Release
# 产出: plugin/DedicatedClientGuard/bin/Release/DedicatedClientGuard.dll-
停止 TShock 服务器。
-
把
DedicatedClientGuard.dll复制到<服务器根>/ServerPlugins/DedicatedClientGuard.dll。 -
启动服务器。首次启动会自动生成
ServerPlugins/DedicatedClientGuard/config.json(默认Enabled: true) 并打印自检信息:[DedicatedClientGuard] 专用包准入守卫已加载。Enabled=True,Mode=Prefix,DryRun=False,令牌=[TSC0DE1A],release白名单=[] [DedicatedClientGuard] ========== 专用包准入自检 ========== 服务端 release 号 : 326 原版客户端将上报 : "Terraria326"(将被拒绝) 专用包将上报 : "TSC0DE1A<release>" 准入模式 : Prefix 强制校验 : 是 release 白名单 : (不限) ==================================
首次部署建议先把
DryRun设为true观察一轮,确认日志里专用包记录的确实是ACCEPT ... raw="<你的令牌><release>"之后,再切回强制校验。 配置错误会导致所有人都进不来。
| 用哪种客户端连 | 期望结果 |
|---|---|
| 专用包 | 正常进入服务器;日志出现 ACCEPT ... token=<令牌> |
| 原版客户端 | 看到你的自定义提示并被踢出;日志出现 REJECT-VANILLA |
不想开游戏也可以直接验:按 Terraria 帧格式
([uint16 小端 总长][uint8 包类型][payload],总长含自身 2 字节)向服务器 7777 端口发一个
ConnectRequest,看应答包类型即可,type=3 为放行(ContinueConnecting),
type=2 为拒绝(Disconnect)。
路径:<服务器根>/tshock/ServerPlugins/DedicatedClientGuard/config.json
(tshock 即 TShock.SavePath;文件不存在时按默认值自动生成)
{
"Enabled": true,
"AllowedTokens": [ "TSC0DE1A" ],
"AllowedReleases": [],
"KickMessage": "&c【准入拒绝】本服务器仅接受官方&l[服务端专用包]&r&c客户端。\n&7请从服务器公告下载专用包后重新连接。",
"AuditLog": true,
"ConsoleNotify": true,
"Mode": 0,
"DryRun": false
}| 字段 | 默认 | 说明 |
|---|---|---|
Enabled |
true |
总开关。false 时插件完全不干预连接(应急回退) |
AllowedTokens |
["TSC0DE1A"] |
令牌白名单,大小写不敏感;可放多个(一服多包) |
AllowedReleases |
[] |
release 白名单;留空 = 不限制 |
KickMessage |
见上 | 踢出提示,支持 &c &a 等颜色标记与 \n 换行 |
AuditLog |
true |
是否写审计行(IP / 原始串 / 令牌 / release) |
ConsoleNotify |
true |
踢出时是否在控制台告警 |
Mode |
0 = Prefix |
0 只校验令牌(推荐,release 升级不受影响);1 = ExactWithRelease 令牌 + release 双校验 |
DryRun |
false |
干跑:仍完整解析并写审计,但不拦截、不接管握手 |
权限:tshock.admin(控制台亦可执行)
/dedicatedguard 查看状态(启用/干跑/模式/令牌白名单/release白名单/审计条数)
/dedicatedguard reload 热重载 config.json(无需重启服务器)
/dedicatedguard test <握手串> 模拟判定,不影响真实连接
例: /dedicatedguard test TSC0DE1A326 → 放行
/dedicatedguard test Terraria326 → 踢出(原版客户端)
/dedicatedguard test EVIL12345 → 踢出(令牌不匹配)
改
config.json后无需重启,/dedicatedguard reload即可生效。 增删插件 DLL 仍需重启服务器。
# 编译插件
dotnet build plugin/DedicatedClientGuard/DedicatedClientGuard.csproj -c Release
# 运行单元测试(21 项,无需启动服务器)
dotnet run --project plugin/DedicatedClientGuard.Tests -c Release测试项目只通过 <Compile Include> 引入 HandshakeParser.cs。该文件不依赖任何 TShock / Terraria 类型,因此可以在没有服务器环境的情况下独立测试。
覆盖场景包括原版串与专用包串的识别、大小写不敏感、畸形/超长/空输入、
tModLoader 风格后缀,以及读取偏移的回归用例(见 7.3)。
若测试项目目标框架(
net9.0)与 SDK 自带运行时不一致,可用DOTNET_ROLL_FORWARD=Major dotnet run ...让其向上滚动到更高版本运行时。
以下几处是开发过程中实际踩过的坑,均已修复并留了回归用例。再次修改本插件前请先读一遍。
TShock 6 的插件加载器位于 bin/TerrariaServer.dll(内含 TerrariaApi.Server.LoadPlugins)。
逐个 DLL 加载时要求:
- 类型
public、非abstract、是TerrariaPlugin子类; - 必须带
[ApiVersion(2, 1)](ServerApi.ApiVersion = new Version(2,1,0,0))。 缺失就静默continue,一行日志都不打印,表现为「DLL 放进去了但没加载、也没报错」; DangerousPluginDetector会拦 TShock 官方标记的恶意程序集;- 另有忽略名单
ServerPlugins/ignoredplugins.txt(按文件名无扩展名匹配)。
排查「插件没加载」先查
[ApiVersion],再看控制台 (TShock.Log.ConsoleError不会写进日志文件)。
配置类里带初始值的集合属性:
public List<string> AllowedTokens { get; set; } = new List<string> { "TSC0DE1A" };Json.NET 反序列化时默认把文件里的元素并入构造函数已初始化的列表(ObjectCreationHandling.Auto),
于是每加载一次配置就多一条重复令牌,config.json 里的令牌随重启次数线性增长。
必须显式声明:
[JsonProperty(ObjectCreationHandling = ObjectCreationHandling.Replace)]
public List<string> AllowedTokens { get; set; } = new List<string> { "TSC0DE1A" };AllowedTokens 与 AllowedReleases 都已标注。
这是本插件最隐蔽的一个 bug,曾导致所有客户端(含专用包)全被拒。
// ❌ 错误:多跳 1 字节
int start = args.Index + 1;
// ✅ 正确
int pos = args.Index; // 载荷起点真实语义(反编译 + 实证双重核对):
| 字段 | 含义 |
|---|---|
args.Index |
载荷起点,已跳过那 1 字节包类型;等于原版里的 num = start + 1 |
args.Length |
整包长度(含那 1 字节包类型) |
依据:
- 反编译
TerrariaServer.dll→HookManager.InvokeNetGetData:Index = e.ReadOffset, 而 OTAPI 中ReadOffset的初值即num = start + 1;随后原版正是reader.BaseStream.Position = num。 - 上游生态中读取同一缓冲的写法一致(例如
Chameleon读PasswordSend用的是new MemoryStream(readBuffer, args.Index, args.Length - 1))。
Index + 1 会吃掉字符串的 7-bit 长度前缀,ReadString() 便把首字符
('T' = 0x54 = 84)当成 84 字节的长度,缓冲里没有那么多字节,于是
抛 Unable to read beyond the end of the stream,落入「解析失败」兜底,结果全部拒绝。
正确读法是自行解析 7-bit 变长长度前缀,不依赖 args.Length 的口径:
int pos = args.Index;
int len = 0, shift = 0;
while (true)
{
byte b = buf[pos++];
len |= (b & 0x7F) << shift;
if ((b & 0x80) == 0) break;
shift += 7;
}
return Encoding.UTF8.GetString(buf, pos, len);回归用例:回归:偏移 = 载荷起点时可还原、回归:误用「载荷起点 + 1」必须失败。
这是一个准入门槛,不是反篡改系统,其能力边界需要说清楚:
- 令牌可被提取。令牌以明文存在于 APK 内,任何人都能反编译取得。 它防的是「普通玩家拿原版客户端直接进来」,不防有逆向能力者改包伪造令牌。
- 防不住二次分发。拿到专用包的人可以转发 APK。要提高门槛需叠加设备绑定或登录鉴权,超出本插件范围。
- 无法创造新的网络消息类型。TShock 的
NetHooks.OnReceiveData会用Enum.IsDefined(typeof(PacketTypes), id)拦截未定义的 MessageID 并静默丢弃, 因此无法要求客户端回送自定义包做挑战—响应。握手串是唯一可用信道。 - 移动端 release 号需实测。
Main.curRelease = 326取自桌面版 1.4.5.8 的反编译源码; 移动端是独立构建,release 值可能不同。若专用包连不上,请以实包抓包为准, 并把实际值填入AllowedReleases(或保持Mode: Prefix忽略 release)。 - 只拦
ConnectRequest,对已建立的连接无效;也不防 SYN 泛洪等传输层攻击。 - 令牌等长约束。客户端侧必须是 8 字节可打印 ASCII(与
"Terraria"等长), 插件侧的令牌匹配则是大小写不敏感的子串前缀语义。
本插件仅供自有服务器管理与安全研究使用。请遵守 Terraria / Re-Logic 的最终用户许可协议 及所在地法律法规,不得用于分发盗版、绕过商业授权或侵犯他人权益。
重打包的客户端仍受原 EULA 约束,请勿将其伪装为官方版本分发。
| 组件 | 说明 |
|---|---|
| 客户端补丁工具 | 遍历 global-metadata.dat 字面量表,等长替换 "Terraria" 为专属令牌并重打包 APK |
| APK 重签名流程 | zipalign + apksigner(v1 + v2 + v3),Android 7.0+ 必须带 v2/v3 签名才能安装 |
| 服务端采集器 | 从 TShock 发行包中提取编译期参考程序集(不属于本仓库,见 refs/README.md) |
重签名后应用签名与 Google Play 版本不同,Google Play 内购会失效; 且包名未改时无法覆盖安装原版,需先卸载。发布更新版必须使用同一个密钥签名。
By ak · 基于 TShock 6.2.1 / Terraria 1.4.5.8 实测,握手解析逻辑 21/21 单元测试通过。