Featured image of post 拆开 TI-Nspire 的 .tns 看里面

拆开 TI-Nspire 的 .tns 看里面

1055 个文件、8 种容器变体、一个手写的 DEFLATE 解码器。用逐字节重建证明读懂了格式。

这台机器上有一个真实的 TI-Nspire 开发环境:OS 3.9.0.463 classic Touchpad、Ndless、从源码构建的 ARM 工具链。

目录里躺着 1055 个 .tns 文件。

.tns 不是一个格式。它是一层 PRG\0 外壳,里面是 Zehn 容器——Ndless 自己的可执行容器格式。而这个格式没有公开文档。

这个项目把它读懂了,并且用「能不能逐字节重建」来证明。


自己解出来的格式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
.tns 文件
├── 装载器桩          0 ~ 0x1EC 字节(常见;也有 0/480/604/616/632 字节的变体)
└── Zehn 容器
    ├── 头 32 字节    8 个小端 u32:
    │                 "Zehn" / version=1 / file_size / reloc_count /
    │                 flag_count / extra_size / alloc_size / entry_offset
    ├── 重定位表      reloc_count × 4 字节
    │                 低 8 位 = 类型,高 24 位 = 偏移
    ├── 标志表        flag_count × 4 字节(同样的 8/24 位切分)
    ├── 额外数据      extra_size 字节(名字/作者/声明等字符串住在这里)
    └── 代码          剩下的;若首个重定位是 FILE_COMPRESSED 则为 zlib 流

两个坑,我都先踩了

坑一:「Zehn」这个字符串会骗你

最直接的思路是——搜文件里 Zehn 这四个字节,从那里开始解。

但装载器桩里也出现了这个字符串。

只搜这 4 个字节会找到诱饵。真正的头必须同时满足三个条件:

  • version == 1
  • 各表长度放得下(不越界)
  • file_size 确实落在文件范围内

光靠一个魔数是不够的。 得用那些字段之间的一致性来互相验证。

坑二:「能力」不等于「要求」

genzehn 的 --color-support 默认是 true。

它的含义是「支持 CX/CM 彩屏」,不是「需要彩屏」。

我一开始理解反了,于是每一个正常程序都被判成「需要彩屏,所以在这台黑白机上跑不了」——一个完全反向的结论,而且看起来非常合理。

正确判据必须看值为 0:0 才表示"不支持这类机器"。

这个坑值得单独说:当一个字段名叫 supports,人们会下意识把它读成 requires。


验证:逐字节重建

rebuild() 从解析出来的字段把容器重新拼回去,然后和原文件比字节。

1
2
3
4
5
$ python -m zehnscope --rebuild "E:\QA\nspire-dev"
11/11 containers rebuilt byte for byte

$ python tests/test_zehn.py
  ok   all 100 containers rebuild identically

这比"解析没崩溃"强得多。

因为偏移、计数、位域切分、字节序、字符串在额外数据里的定位——任何一处理解错,字节就对不上。

它一次同时检验所有假设。

语料是 1055 个真实文件,不是精选样本

其中 100 个是真正的 Ndless 程序,955 个只是沿用了 .tns 扩展名的文档/资源(boot1.img.tns、Hitori 关卡、Liero 素材……)。

一个只见过"干净样本"的解析器,证明力弱得多。跨十年、多个工具链、8 种不同桩的 100 个真实容器,才是像样的考验。


手写一个 DEFLATE 解码器

Zehn 的压缩用 zlib。

但语料里没有一个压缩容器——光靠真实文件测不到这条路。

所以自己按格式造一个再读回来。

解码器是手写的 RFC 1951 + 1950:stored / fixed / dynamic 三种块、LZ77 回引用、Adler-32 校验。

然后对照系统 zlib,在 40 组样本上比对输出(10 种载荷 × 4 个压缩级别)。

一个实现自己和自己一致,说明不了什么。两个独立实现一致,才算证据。


一个被窄样本骗过的断言

tests/test_zehn.py 打印出来的实测结果:

桩大小容器数
0x1EC (492)53
017
63211
4808
6166
6045

17 个容器完全没有桩——容器就在文件偏移 0。

我最初写了一条「所有程序共用一个桩」的断言,在 14 个 samples-tns 上全部通过,在全语料上直接失败。

这正是把断言写在窄样本上会骗人的例子:样本越小,错误越自信。


兼容性判定:一个被推翻的直觉

compat.py 拿程序自己声明的能力和具体机器对判。

这台机器的约束很紧:classic 非 CX、320×240 黑白、Touchpad、Ndless r2016、64MB。

原本的直觉解释是:「SDK 是 r2022、设备是 r2016,所以自写的程序被拒载重启」。

但实测结论是:这 100 个程序里,没有一个声明 Ndless 版本窗口(NDLESS_VERSION_MIN / REVISION_MIN 全缺)。

也就是说 版本不匹配不会导致装载被拒。

那条"合理"的解释,直接被数据否掉了。真正的原因得往别处找——ut_read_os_version_index() 的地址哈希、sc_addrs_ptr 查表、或者 classic 上看门狗没关。

这是这个项目最值钱的一条:一个解释听起来越合理,越要拿数据去撞它。


还能往哪走

  • 容器写入器:现在只会重建已有容器。接上 ELF 解析就能把任意 ARM ELF 打包成合法 .tns——包括 genzehn 那部分(扫 .rel.text、发 ADD_BASE/ADD_BASE_GOT、处理 GOT 回滚)
  • OS 镜像:boot1.img.tns / boot2.img.tns / diags.img.tns 不是 Zehn 容器,是 TI 的 OS/引导镜像,格式完全不同
  • syscall 面:把容器里的重定位和 syscall-list.h 的 ID 表关联,就能静态列出每个程序用到的 syscall,再和 r2016 的表做差集——那才是"为什么重启"的正确问法

看 100 个程序的容器报告 →


技术细节

文件作用
zehnscope/core.py容器解析 + 逐字节重建
zehnscope/inflate.py手写 DEFLATE/zlib 解码器
zehnscope/__main__.pyCLI:inspect / --rebuild
compat.py能力 vs 机型判定(含"能力≠要求"的说明)
tests/test_zehn.py18 项断言

逆向工程真正的方法论,不是"猜对格式"。

是找到一种只有完全理解它才能通过的检验,然后不断撞上去。

逐字节重建就是这样一种检验。

使用 Hugo 构建
主题 Stack 由 Jimmy 设计