# Virtuoso CXT 文件结构、生成原理与三种恢复方案 ## 1. 文档范围 本文介绍以下内容: 1. CXT 文件是什么,以及它与普通 SKILL 源文件有什么区别。 2. CXT 是怎样从 SKILL/SKILL++ 代码生成的。 3. CXT 文件在磁盘上的整体组织结构。 4. `procedure`、普通变量、`defclass`、`defgeneric` 和 `defmethod` 在 CXT 中如何表示。 5. CXT 加载时,保存地址如何被重定位成当前进程中的有效地址。 6. 为什么受保护的 VCODE 仍然可以被恢复。 7. 三种恢复方案: - 传统方案:修改 Virtuoso 的 `*.so`,让 `pp` 忽略保护判断。 - 创新方案一:使用 GDB 修改正在运行的 Virtuoso 内存,再使用 `pp`。 - 创新方案二:用 C 程序直接加载 `*.so`,脱离 Virtuoso 和 `pp`。 本文不展开 CXT 外层的简单 XOR 处理。这里关注的是完成外层处理之后,CXT 内部真正有用的对象图、VCODE、类和方法数据。 ### 1.1 版本边界 文中的精确偏移来自Virtuoso 618版本运行库 不同 Cadence/Virtuoso 版本可能改变结构大小、字段含义或内部函数地址。因此应把本文理解为: - 对当前分析版本已经验证的结构说明; - 分析其他版本时的可靠方法; - 不是对所有历史版本都保证不变的公开 ABI 文档。 例如,version 602 的 FunObj 是 40 字节,而 version 601 的旧 FunObj 路径使用 32 字节布局。 --- ## 2. 先建立一个正确直觉:CXT 不是源代码压缩包 初学者最容易产生的误解是: > “CXT 可能只是把 `.il` 源文件压缩或加密后放进去,解开以后就能拿回原文。” 实际情况不是这样。 CXT 更接近一份“SKILL 运行时对象图快照”。源代码被读取、求值或编译以后,运行时会形成很多对象: - Symbol,也就是变量名和函数名; - 普通变量的最终值; - List/Cons; - String; - Array; - FunObj; - VCODE; - class metadata; - generic method table; - method 安装记录; - TopForm; - namespace 和导出信息。 CXT 保存的是这些运行时对象及其相互引用,而不是原始文本。 可以把两者类比为: ```text SKILL 源文件 类似“建造说明书” CXT 类似“建造完成后的零件、连接关系和部分可执行程序” ``` 因此,恢复出来的代码通常可以在语义上接近原始代码,但以下内容往往已经不可逆地丢失: - 注释; - 空格和换行风格; - 原始括号排版; - 局部变量原始命名的一部分; - 某些宏展开前的写法; - 被优化掉的中间表达式; - 普通变量赋值时原始 RHS 的计算过程。 例如,普通快照路径下: ```skill foo = 1 + 2 ``` 和: ```skill foo = 3 ``` 最终都可能只留下“Symbol `foo` 的值为整数 3”。仅根据最终 binding 无法判断原来是哪一种写法。 --- ## 3. 阅读本文前需要认识的几个术语 ### 3.1 Symbol Symbol 是 SKILL 运行时里的“名字对象”。 同一个 Symbol 可以同时关联: - 普通变量值; - 函数定义; - property list; - namespace; - 保护标志。 因此,变量名和函数名不是两种完全不同的对象。它们都可以从 Symbol 开始,只是使用的 binding 槽不同。 ### 3.2 Binding Binding 可以理解为“某个名字当前绑定到什么对象”。 在当前 64 位版本中: - Symbol `+0x18` 是普通变量值 binding; - Symbol `+0x20` 是函数定义 binding。 这也是下面两个函数的区别: ```text ilGetSym(symbol) -> 读取 symbol + 0x18,普通变量值 ilGetd(symbol) -> 读取 symbol + 0x20,函数定义 ``` ### 3.3 ILValue ILValue 是 SKILL 运行时中常见的 64 位值表示。它既可能直接保存一个小整数,也可能保存指向对象的指针。 常见低位标记如下: | 形式 | 含义 | | ---------------- | ---------------------------- | | `0` | `nil` | | `value & 1 != 0` | 立即数整数,通常为 `(n << 2) | | `value & 2 != 0` | List/Cons 引用 | | 低两位为`00` | 对齐后的堆对象指针 | 例如: ```text 整数 1 -> (1 << 2) | 1 = 0x5 整数 123 -> (123 << 2) | 1 = 0x1ED ``` 因此,小整数不一定需要单独的 integer object。 ### 3.4 FunObj FunObj 是 SKILL 可执行函数对象。对于编译后的 SKILL 函数,它会关联: - 参数数量和参数描述; - VCODE 向量; - 指令入口; - 函数子类型; - 保护标志。 ### 3.5 VCODE VCODE 是 SKILL 运行时使用的 64 位 qword 混合指令流。它不是字符串,也不是原始源码。 VCODE 中可以混合出现: - 指令 word; - 立即数; - Symbol、String、List 等对象引用; - 默认参数和 literal; - 尾部 literal pool。 ### 3.6 `pp` `pp` 是 Virtuoso/SKILL 环境中的 pretty printer。对于 VCODE 函数,它并不只是打印内存,而是会调用官方 VCODE 反编译逻辑,把 FunObj 转换回可打印的 SKILL form。 ### 3.7 oblist oblist 是运行时已 intern 的 Symbol 列表。通过比较加载 CXT 前后的 oblist 及其 binding,可以自动发现 CXT 新增或改写的函数和类,而不需要猜测函数名前缀。 --- ## 4. CXT 的生成原理 ### 4.1 总体流程 下面的流程图展示了从源代码到 CXT 的主要阶段: ```mermaid flowchart TD A["SKILL / SKILL++ 源文件"] --> B["setContext:进入 Context 构建期"] B --> C{"顶层内容采用哪条路径"} C -->|"普通 load / eval"| D["立即执行赋值并编译定义"] C -->|"TopForm 包装"| E["保存待加载时执行的 form 和顺序号"] D --> F["形成运行时对象图"] E --> F F --> G["确定 Context 自有页面和可达对象"] G --> H["复制 Symbol、List、FunObj、类和方法数据"] H --> I["收集字符串到 string blob"] H --> J["收集 Array / VCODE 到 qword vector"] H --> K["序列化用户类型、namespace 和导出信息"] I --> L["写入可变长 CxtHeader"] J --> L K --> L L --> M["写入 BlockHeader 和 object cells"] M --> N["写入 tail 数据"] N --> O["生成 CXT 文件"] ``` ### 4.2 `setContext` 的作用 `setContext` 不只是设置一个字符串名称。它会让运行时进入 Context 构建状态,并区分: - 构建 Context 之前已经存在的基础对象; - 构建期间新建或导出的对象; - 当前 Context 依赖的旧对象; - 需要写进 CXT 的对象页面和尾部数据。 这样生成的 CXT 不需要把整个 Virtuoso 进程内存全部保存,只需要保存当前 Context 的对象及其必要引用。 ### 4.3 普通求值/快照路径 普通路径大致是: ```text setContext -> load/eval 源文件 -> procedure 被编译成 FunObj/VCODE -> 普通赋值立即写入 Symbol value binding -> class/generic/method 在运行时安装 -> saveContext 保存最终对象图 ``` 例如: ```skill foo = list(1 "x") ``` 赋值执行以后,`ilSet1` 会把最终 List 对象写入 `foo` Symbol 的 `+0x18`。保存时 CXT 会沿着这个引用继续保存 List cell、String descriptor 和字符串内容。 这条路径保存的是结果,不保存原始表达式。若 RHS 中包含时间、随机数或外部查询,通常保存的是制作 CXT 当时得到的结果。 ### 4.4 TopForm 路径 CXT 还支持 `topContextSkillForm` 用户类型。 只有上层编译/打包流程显式调用 `_makeTopSkillForms`、`ilfMakeSkillTopForm` 或 `loadTopForm` 一类机制时,顶层 form 才会以 TopForm 方式保存。`saveContext` 本身不会把所有普通赋值自动转换成 TopForm。 当前版本中,一个 TopForm **运行时对象**约为 0x60 字节,其中重要字段是: | 偏移 | 含义 | | --------------- | ------------------------- | | `+0x08` | 待执行的 ILValue form/AST | | `+0x10...+0x50` | 执行环境和模式相关状态 | | `+0x58` | 顺序号 | 加载 CXT 时,`iliTopFormLoadContext` 会把 TopForm 按顺序号放入表中。普通对象和 Symbol 完成重定位以后,`iliExecuteTopForms` 再按顺序调用 `iliExecSkillForms`。 这里的 0x60 是内存对象大小,不代表磁盘上直接平铺一个 0x60 字节裸结构。`iliTopFormSaveContext` 会把 10 个 ILValue 字段和顺序号编码成 6 组 16 字节的 user-type pairs,再通过 CXT 的 User-type vector 保存;加载回调负责还原为运行时记录。 这意味着: - 普通快照路径:RHS 在制作 CXT 时执行,加载时恢复结果; - TopForm 路径:RHS 在每次加载 CXT 时重新执行; - 两种记录可以因打包流程而同时出现; - 如果两者同时存在,TopForm 后执行,可能覆盖前面恢复的 snapshot binding。 所以,看到“CXT 中只有变量赋值”时,不能只根据源码形式断言它一定采用哪条路径。应检查实际 CXT 是否包含 `topContextSkillForm` 用户类型记录。 --- ## 5. CXT 文件的总体组织结构 ### 5.1 从文件头到文件尾 ```mermaid flowchart TB A["可变长 CxtHeader"] --> B["Block 0:32-byte BlockHeader + object cells"] B --> C["Block 1:32-byte BlockHeader + object cells"] C --> D["更多对象 Block"] D --> E["Tail 起点"] E --> F["String blob:名称和字符串字节"] F --> G["Qword vector:Array 元素、VCODE 等"] G --> H["User-type vector"] H --> I["可选 Namespace / ZIP vector / 其他兼容数据"] ``` 可以把文件分为三层: 1. **CxtHeader**:描述版本、大小、tail、保存时基址和 block 数量。 2. **对象 Block**:保存固定大小的运行时 object cells。 3. **Tail**:保存不适合直接放在固定 cell 中的变长数据。 ### 5.2 已确认的 CxtHeader 关键字段 下面的偏移针对本文分析的 64 位 version 602 格式: | 偏移 | 含义 | | --------------- | ---------------------------------------------------------------------- | | `+0x00...+0x03` | 平台/格式标识;当前 64 位保存端写入`00 00 32 00`,加载器要求首字节为 0 | | `+0x08` | version 602 中 Tail/string blob 起点相对当前 CXT record 起点的文件偏移 | | `+0x10` | 64 位字节序指纹 | | `+0x18` | 32 位字节序指纹 | | `+0x1C` | 新格式/可选 ZIP vector 相关标志;不能笼统称为“加密位” | | `+0x20` | CXT 格式版本,当前已确认 601/602 路径 | | `+0x28` | 版本标识字符串的保存时引用;字符串内容进入 string blob | | `+0x30` | Header 总长度,常见关系为`0x98 + 8 * blockCount` | | `+0x38` | 保存过程累计的 block 边界/尺寸类字段,具体语义与版本相关 | | `+0x40` | 保存时的`unbound` 哨兵值/引用 | | `+0x48` | namespace 区字节数;当前常见保存路径为 0,加载器仍支持非 0 | | `+0x50` | 可选 ZIP vector 的保存基址/指针字段 | | `+0x58` | 可选 ZIP vector 字节数 | | `+0x60` | Array/qword vector 区字节数 | | `+0x68` | 保存时 Array/qword vector 的运行时基址 | | `+0x70` | string blob 字节数 | | `+0x78` | 保存时 string blob 的运行时基址 | | `+0x80` | User-type vector 保存时基址 | | `+0x88` | User-type vector 的 pair 数量;对应磁盘数据通常为`16 * pairCount` 字节 | | `+0x90` | blockCount | | `+0x98...` | block 旧页地址表;固定头已经容纳第一项,后续项构成可变扩展 | Header 里的“保存时基址”非常重要。CXT 中大量字段保留的是保存进程中的地址形态,而不是简单的文件偏移。加载器依靠这些基址和旧页地址表完成重定位。 version 602 的 header 会先处理固定 `0xA0` 字节。当 `blockCount = N >= 1` 时,还会追加 `8 * (N - 1)` 字节旧页地址,因此: ```text headerSize = 0xA0 + 8 * (N - 1) = 0x98 + 8 * N ``` `+0x08` 的 tailOffset 也有明确版本边界:version 602 保存端会在所有对象 block 写完后回填它;version 601 的兼容路径可能把 `+0x04...+0x0F` 用作短 Context 名,不应直接套用 version 602 的解释。 version 602 中可以用两种方式得到 tail 起点: ```text 首选: tailStart = recordStart + header.tailOffset 离线校验: tailStart = recordStart + headerSize + Σ(0x20-byte BlockHeader + block.usedBytes) ``` 正常 block 的 `usedBytes` 应为 `cellSize` 的整数倍。若解析不可信文件,仍应先检查整除关系和文件边界,不能直接信任 header 中的长度。 ### 5.3 BlockHeader 每个对象 block 前面有一个 32 字节 BlockHeader: | 偏移 | 类型 | 含义 | | ------- | -------- | ------------------------------------------ | | `+0x00` | `uint16` | cellSize | | `+0x02` | `uint16` | blockType | | `+0x04` | `uint16` | usedBytes | | `+0x06` | `uint16` | flags,已确认`0x04` 表示 static page | | `+0x0C` | - | 调试名称/描述区域,内容依版本和 block 而定 | | `+0x18` | `uint64` | 页级 bookkeeping/link | | `+0x20` | - | object cells 起点 | 同一个 block 中的 cell 大小固定,由 `cellSize` 指定。加载器根据 `blockType` 选择对应的 XDR/字节序转换函数和指针调整逻辑。 ### 5.4 主要 blockType | blockType | 主要对象 | 常见 cell 大小 | 说明 | | --------: | ------------------ | ------------------------: | ----------------------------------------------- | | 1 | FunObj | 40 字节,旧版可能 32 字节 | procedure、generic、method body、class 相关对象 | | 2 | List/Cons | 24 字节 | car/cdr 对象图 | | 3/4 | boxed/兼容整数对象 | 16 字节 | 普通小整数通常直接使用 tagged ILValue | | 5 | Symbol | 56 字节 | 名称、变量值、函数定义、plist、namespace | | 6 | StdObj | 24 字节 | 标准对象数据 | | 7 | Environment | 24 字节 | 闭包/环境相关对象 | | 8 | Float | 16 字节 | double 数据通常位于`+0x08` | | 9 | String descriptor | 16 字节 | 字符内容位于 tail string blob | | 11 | Array descriptor | 24 字节 | 元素向量位于 tail qword vector | | 12 | Other | 16 字节 | 其他内部对象 | | 20...219 | User type | 由注册类型决定 | TopForm、表和扩展用户类型等 | 这张表只列出恢复 CXT 时最常见的类型。内部还存在垃圾回收、转发或兼容用途的特殊类型,不应仅凭一个字节就盲目修改。 ### 5.5 Tail 的作用 固定大小 cell 适合保存结构描述,但不适合直接内嵌任意长度的字符串、数组或 VCODE。因此 CXT 把变长内容集中放在 tail。 #### String blob String blob 主要保存: - Symbol 的 pname; - String 对象的字符数据; - 其他需要 C 字符串的元数据。 Symbol 的 `+0x08` 直接指向 pname 字节;String 对象则先经过 type-9 descriptor,再指向字符数据。 #### Qword vector Qword vector 主要保存: - Array 元素; - FunObj 的 VCODE; - literal 引用向量; - 其他 64 位槽数组。 因此,“qword vector 区”不能简单等同于“VCODE 区”。即使 CXT 没有任何 procedure,只要存在 Array,它仍可能有 qword vector 数据。 #### User-type 和可选兼容数据 注册用户类型可以通过自己的 save/load callback 保存变长内容。TopForm 就通过用户类型回调保存 form 和执行状态。 当前 version 602 主保存路径中,核心 tail 顺序已经确认是: ```text string blob -> Array/qword vector -> User-type vector -> 可选的 namespace/ZIP vector/兼容扩展数据 ``` 加载器保留 namespace 数据读取能力,但当前分析的常见保存路径会把相应计数初始化为 0,因此不能假定每个 CXT 都实际包含 namespace payload。可选 ZIP vector 也只有相关 header 字段非零时才存在。 --- ## 6. 最关键的对象结构 ### 6.1 Symbol:变量名和函数名的共同入口 当前版本的 type-5 Symbol 固定为 56 字节: | 偏移 | 含义 | | ------- | ------------------------------------------------------------------------------ | | `+0x00` | 对象头,byte 0 为 type 5 | | `+0x01` | Symbol 状态/子类型 flags | | `+0x02` | 函数侧 flags 和保护相关状态;已确认 bit`0x02` 会参与 `pp` 的 read-protect 判断 | | `+0x03` | 变量侧 flags;bit`0x01` 与变量写保护有关,bit `0x08` 与 guard 有关 | | `+0x08` | pname 指针 | | `+0x10` | property list | | `+0x18` | 普通变量 value binding | | `+0x20` | function binding | | `+0x28` | 全局 Symbol hash bucket collision 链 | | `+0x30` | namespace | `ilXDRSymbol` 会对 `+0x08`、`+0x10`、`+0x18`、`+0x20`、`+0x28` 和 `+0x30` 六个 qword 做格式转换,说明这些字段都属于实际序列化的 Symbol cell。 加载时不会简单保留 CXT 中的重复 Symbol。`ilAddCntxtSym`/`ilAddCntxtSymNs` 会按照 pname 和 namespace: 1. 查找当前运行时是否已有同名 Symbol; 2. 已存在则合并; 3. 不存在则创建并加入 oblist; 4. 重定位 plist/value/function; 5. 重新建立 Symbol hash 链。 ### 6.2 只有“变量名 = 值”时怎样存储 假设源文件只有: ```skill foo = list(1 "x") ``` 普通快照路径的对象图可以简化为: ```text type-5 Symbol "foo" +0x08 -> tail string blob 中的 "foo\0" +0x18 -> type-2 Cons #1 +0x20 -> 空函数 binding type-2 Cons #1 car = tagged integer 1,也就是 0x5 cdr -> type-2 Cons #2 type-2 Cons #2 car -> type-9 String descriptor cdr = nil type-9 String descriptor +0x08 -> tail string blob 中的 "x\0" ``` 需要特别区分: - `nil` 是合法值 0; - 未绑定变量使用 `ilcUnbound` 特殊对象; - 二者不是一回事。 对象之间依靠引用连接,而不是被拍平成文本。因此共享引用和循环引用在支持的对象类型中可以被保留。 如果变量的值本身是 FunObj、closure 或保存了函数对象,即使没有写命名 `procedure`,CXT 仍可能包含 FunObj/VCODE。 ### 6.3 version 602 的 FunObj 当前版本的 FunObj 为 40 字节。已确认的关键字段如下: | 偏移 | 含义 | | ------- | ---------------------------------------------------------------------------------------- | | `+0x00` | byte,type = 1 | | `+0x01` | FunObj subtype 和调用相关标志;低 3 位为 0 时是特殊 wrapper/closure 路径,字段解释会变化 | | `+0x02` | flags;bit`0x80` 是 VCODE read-protected | | `+0x03` | optional/formal 参数相关信息 | | `+0x04` | `uint16`,required 参数数 | | `+0x08` | `uint64`,VSize,即总 qword 数 | | `+0x10` | 调试符号或附属引用,具体语义依版本/subtype 而定 | | `+0x18` | VCODE qword vector 基址 | | `+0x20` | 可执行指令入口 | 上表的 VCODE 边界解释适用于普通、非 wrapper 的 VCODE FunObj。若低 3 位 subtype 为 0,应先沿内部引用找到真正受保护/可执行对象,不能直接把所有字段都按普通函数解释。 VCODE 的边界可以按下面方式理解: ```text base = *(fun + 0x18) entry = *(fun + 0x20) end = base + 8 * *(fun + 0x08) startIndex = (entry - base) / 8 ``` 逻辑区域是: ```text [base, entry) 参数、默认值、literal 或其他前缀数据 [entry, FunEnd) 主要指令流 [FunEnd, end) 可选 trailing literal pool ``` ### 6.4 VCODE qword 如何区分指令和 literal VCODE 是 64 位 word 流。当前版本中: ```text word bit 0 = 1 -> 指令 word word bit 0 = 0 -> literal / ILValue 对象引用 ``` 字符串不会直接内嵌成一段 VCODE 文本。典型引用关系是: ```text VCODE literal -> type-9 String descriptor -> descriptor + 0x08 -> CXT tail string blob ``` VCODE 在文件中的概念位置可以这样换算: ```text tailStart = recordStart + header.tailOffset vectorFileStart = tailStart + stringBytes vcodeFileOffset = vectorFileStart + (fun.vcodeBase - header.savedArrayBase) 其中 header.savedArrayBase 就是 Header `+0x68` 保存的旧 Array/qword-vector 基址。 ``` 但是,CXT 还存在旧页地址、tagged ILValue、基础 Context 引用和用户类型。实际恢复时,让官方加载器先完成重定位,再读取运行时 FunObj,通常比纯手工解析文件偏移更可靠。 ### 6.5 各种定义如何挂接 #### procedure / nprocedure / mprocedure 典型关系: ```text type-5 Symbol +0x20 function binding -> type-1 FunObj -> VCODE qword vector ``` 函数名来自 Symbol `+0x08` 的 pname,而不是 VCODE 本身。 #### defgeneric generic 仍以 FunObj 为核心。当前版本中: ```text (funObj[1] & 7) == 4 ``` 可用于识别 generic subtype。generic 还关联 method table。 #### defclass class 不等同于一段特殊“defclass 源码字符串”。它主要由: - class object; - class name; - superclass; - slot specs; - Symbol 的 class property; - 相关 FunObj subtype/metadata; 共同表示。 当前分析版本中 class 相关 FunObj subtype 满足: ```text (funObj[1] & 7) == 5 ``` 恢复 `defclass` 时应读取 class metadata,再重建父类和 slot 列表,而不是在 CXT 中搜索 `defclass(` 字符串。 #### defmethod method body 本身仍是普通 FunObj/VCODE。但是 method 的完整语义还需要: - 它属于哪个 generic; - specializer 列表; - primary/before/after/around role; - method FunObj。 这些信息位于 OOP 安装记录中。当前版本观察到的主要形式是: ```text (opcode genericSymbol specializers methodFunObj ...) ``` 其中: | opcode | role | | -----: | ------- | | 1 | primary | | 2 | before | | 3 | after | | 4 | around | 因此,恢复 defmethod 不能只扫描有名字的 procedure。很多 method body 并没有一个可供猜测的普通全局函数名。 --- ## 7. CXT 的加载原理 ### 7.1 为什么保存时的指针不能直接使用 CXT 中的对象引用最初来自制作 CXT 的进程。例如,某个 Symbol 的 value 可能保存为旧地址: ```text 0x00007f12xxxxxxxx ``` 另一个进程加载时,由于 ASLR、分配器状态和基础 Context 不同,新对象不可能仍位于同一地址。因此必须重定位。 ### 7.2 加载流程图 ```mermaid flowchart TD A["打开 CXT"] --> B["读取并校验 Header、版本、位数和字节序"] B --> C["根据 BlockHeader 分配对象页面"] C --> D["读取 object cells"] D --> E["读取 string blob、qword vector 和用户类型数据"] E --> F["建立旧页地址到新页地址的映射"] F --> G["调整 Symbol、List、FunObj、Array 等内部指针"] G --> H["按 pname / namespace 合并 Symbol"] H --> I["恢复变量值、函数 binding、class 和 generic"] I --> J["运行 User-type load callbacks"] J --> K["安装 OOP method 记录"] K --> L["按顺序执行 TopForm"] L --> M["执行 Context init / auto-init"] M --> N["CXT 对象出现在当前 SKILL 运行时"] ``` ### 7.3 Symbol 合并的顺序 加载 type-5 Symbol 时,核心步骤是: 1. 用 `newStringBase - savedStringBase` 调整 pname; 2. 重定位 namespace; 3. 用 `ilAddCntxtSym` 或 `ilAddCntxtSymNs` 查找/创建真实运行时 Symbol; 4. 复制必要 flags; 5. 重定位 plist; 6. 把保存时的 `ilcUnbound` 映射为当前进程的 `ilcUnbound`; 7. 重定位并安装普通变量 value; 8. 重定位并安装 function binding; 9. 重建 Symbol hash 链。 这解释了为什么同名函数或变量可以覆盖当前进程中的旧 binding,而不是生成一个对用户不可见的重复 Symbol。 --- ## 8. 源码恢复的基本原理 恢复 CXT 并不是“把每个字节翻译回源代码”,而是: ```text 加载对象图 -> 完成指针重定位 -> 找到 Symbol / class / generic / method -> 找到 FunObj 和 VCODE -> 调用 VCODE 反编译器得到 AST/form -> 把 AST/form 打印成 SKILL 文本 ``` ### 8.1 `pp` 为什么能打印 VCODE `pp` 的关键调用链可以概括为: ```mermaid flowchart LR A["pp(function)"] --> B["ilPp / ilPp1"] B --> C{"iliFunIsReadProtected?"} C -->|"是"| D["拒绝打印或报告 read-protected"] C -->|"否"| E["iliVcodeDecompFun / iliVcodeDecomp"] E --> F["生成 SKILL AST/form"] F --> G["pretty print 到 poport"] ``` 保护位不是 VCODE 加密。VCODE 指令和 literal 仍然存在;保护判断只是阻止普通 `pp` 继续进入反编译路径。 当前版本的 read-protect 判断并不只看一个位置。`iliFunIsReadProtected` 会检查 Symbol 侧的保护位,并根据 FunObj subtype 在两个函数侧位置中二选一: ```text Symbol + 0x02 的 bit 0x02 如果 (FunObj[1] & 7) != 0: 检查 FunObj + 0x02 的 bit 0x80 如果 (FunObj[1] & 7) == 0: 不检查 FunObj 自身的这个 bit; 改为跟随 *(FunObj + 0x18),检查被指向 guard 对象 +0x02 的 bit 0x80 ``` 也就是说,稳妥的 GDB 方案应核对 Symbol 侧 `0x02`,再按 subtype 核对 FunObj 或间接 guard 对象侧的 `0x80`,不能无条件把两个函数侧位置都当成保护位。上面的偏移只适用于本文分析的精确版本,不能视为跨版本公开格式。 ### 8.2 为什么优先复用官方反编译器 完全手写 VCODE 反编译器需要解决: - opcode 表; - 每个 opcode 的字段布局; - 控制流和跳转; - 参数和默认值; - literal pool; - 对象引用重定位; - 不同版本兼容; - SKILL/SKILL++ AST 重建。 而 `*.so` 本身已经包含官方加载器和 VCODE decompiler。因此三种实用恢复方案的共同思想都是: > 尽量复用 Cadence 自己的运行时对象模型和反编译器,而不是从零猜完整 VCODE 指令集。 --- ## 9. 三种恢复方案总览 ```mermaid flowchart TD A["需要恢复的 CXT"] --> B{"选择恢复路线"} B --> C["方案一:修改磁盘上的 Virtuoso .so"] B --> D["方案二:GDB 修改运行时内存,再调用 pp"] B --> E["方案三:C 程序直接调用 *.so"] C --> F["Virtuoso 内 pp 忽略保护判断"] D --> G["只清当前进程中的 Symbol / FunObj 保护位"] G --> H["Virtuoso 内 pp 输出源码"] E --> I["独立 Linux 进程加载 CXT"] I --> J["直接调用 VCODE decompiler 并打印 AST"] ``` | 对比项 | 方案一:改`.so` | 方案二:GDB 内存修改 +`pp` | 方案三:C 直接调用`.so` | | ------------------------ | ---------------------------------------- | ------------------------------------- | ------------------------------------------- | | 是否需要启动 Virtuoso | 是 | 是 | 否 | | 是否修改磁盘库文件 | 是 | 否 | 否 | | 是否依赖`pp` | 是 | 是 | 否 | | 保护位处理 | 永久绕过判断 | 只改当前进程内存 | 直接调用底层反编译器,可不走`pp` 门禁 | | 修改生命周期 | 直到换回原始`.so` | 进程退出、对象重载后失效 | 每次在独立进程中重新加载和恢复 | | 自动化难度 | 中 | 中 | 高,但完成后批处理最好 | | `defclass` / `defmethod` | 只改`pp` 不够,仍需元数据和 method table | 可以做,但需要额外 OOP 枚举与临时绑定 | 工具可以集成 class metadata 和 OOP 记录处理 | | native /`binary` | 无 VCODE,不能恢复 | 无 VCODE,不能恢复 | 无 VCODE,不能恢复 | | 对版本变化敏感度 | 很高 | 中到高 | 高,依赖私有 ABI | | 适合场景 | 临时研究旧版本 | 已有 Virtuoso 环境、单次或少量恢复 | 批量、无人值守、脱离 Virtuoso | | 主要风险 | 破坏安装、影响所有进程 | 错地址可能导致当前进程崩溃 | 兼容库和 ABI 不匹配可能崩溃 | --- ## 10. 方案一:修改 Virtuoso 的 `.so`,让 `pp` 忽略保护 这是传统二进制 patch 方案。基本思路是: 1. 在对应版本 `*.so` 中找到 `ilPp1` 或 `iliFunIsReadProtected`; 2. 找到“受保护则拒绝打印”的条件分支; 3. 修改分支,使其总是进入 VCODE 反编译路径,或者让保护判断恒定返回 false; 4. 让 Virtuoso 加载修改后的库; 5. 正常使用 `pp` 输出函数。 本方案只做简要介绍,因为它的问题比较明显: - 修改的是磁盘文件,会影响使用该库的所有 Virtuoso 进程; - 每个版本的机器码地址和分支都可能不同; - patch 错误会导致 Virtuoso 无法启动或随机崩溃; - 软件升级、校验或重新安装后 patch 会失效; - 必须保留原始库和校验值,并只对有权分析的文件使用。 若确实采用该方案,至少应在副本上操作,不要直接覆盖唯一的官方库。 --- ## 11. 方案二:GDB 修改运行时内存,然后使用 `pp` > 用户有时会写成 “GDP”,本文使用正确工具名 **GDB(GNU Debugger)**。 这是在现有 Virtuoso 环境中最实用、又不修改磁盘 `.so` 的方案。 ### 11.1 核心思想 ```text 官方 *.so 不变 -> Virtuoso 正常加载 CXT -> GDB 附加到 Virtuoso 进程 -> 找到目标 Symbol 和 FunObj -> 清除 Symbol + 0x02 的 0x02 -> 清除 FunObj/guard + 0x02 的 0x80 -> 继续/脱离进程 -> 使用官方 pp 反编译并打印 ``` 内存修改只影响当前 Virtuoso 进程。进程退出以后,修改自然消失。 ### 11.2 详细流程图 ```mermaid flowchart TD A["启动与 CXT 版本匹配的 Virtuoso"] --> B["把 CXT 放到 64bit 子目录并 loadContext"] B --> C["确认 getd(name) 是 funobj/VCODE,而不是 native binary"] C --> D["GDB attach 到 Virtuoso PID"] D --> E["ilMakeSym(name) 得到 type-5 Symbol"] E --> F["读取 Symbol + 0x20 的 function binding"] F --> G{"binding 的 type byte"} G -->|"1:直接 FunObj"| H["使用该 FunObj"] G -->|"5:Symbol wrapper"| I["再读取 wrapper + 0x20"] I --> H G -->|"其他或 nil"| J["停止:未加载或不是可恢复 VCODE"] H --> K["验证 FunObj byte 0 == 1"] K --> L["读取 flags 和 subtype,但暂不写内存"] L --> R{"FunObj subtype 是否为 0"} R -->|"是"| S["解析、验证并读取 fun+0x18 guard"] R -->|"否"| M["全部目标验证完成"] S --> M M --> Q["统一清除 Symbol 0x02 与 FunObj/guard 0x80"] Q --> N["continue 或 detach"] N --> O["在 Virtuoso 中调用 pp"] O --> P["把 poport 重定向到 recovered.il"] ``` ### 11.3 前提条件 开始前应满足: - Virtuoso、CXT 和 `*.so` 位数及版本匹配; - GDB 与 Virtuoso 在同一 Linux 主机; - 当前用户有权限 attach,或具有 `sudo`; - 目标 CXT 已经成功加载; - 目标定义是 SKILL VCODE,而不是 native/binary 函数; - 对重要 Virtuoso 会话先保存工作,避免调试错误造成数据丢失。 ### 11.4 正确放置并加载 CXT 64 位 `loadContext` 通常会在给定 base path 的 `64bit` 子目录寻找实际文件。 示例: ```sh mkdir -p /tmp/cxt_recover/64bit cp input.cxt /tmp/cxt_recover/64bit/demo ``` 在 Virtuoso SKILL 环境中: ```skill setSkillPath(cons("/tmp/cxt_recover" getSkillPath())) loadContext("/tmp/cxt_recover/demo") ``` 这里传给 `loadContext` 的是 base path `/tmp/cxt_recover/demo`,真正的文件位于 `/tmp/cxt_recover/64bit/demo`。 先检查函数是否存在: ```skill getd('someFunction) type(getd('someFunction)) ``` 一般判断: - `funobj`:可以继续检查 VCODE; - `binary`:这是 native/binary 函数,没有可供 VCODE decompiler 恢复的 SKILL body; - `nil`:函数没有加载成功、名字不对,或者它不是普通全局函数。 ### 11.5 找到 Virtuoso PID 并 attach 在 Linux 上查找进程: ```sh pgrep -af virtuoso ``` 不要只因为某个 PID 最新就直接 attach。可以继续确认: ```sh ps -fp readlink -f /proc//exe ``` 确认它是专门用于恢复的会话后: ```sh sudo gdb -q -nx -p ``` 进入 GDB 后先关闭分页: ```gdb set pagination off set print pretty on info sharedlibrary libil_sh info address ilMakeSym ``` 注意:GDB attach 后,Virtuoso 会暂停。这是正常现象。在执行 `continue` 或 `detach` 之前,Virtuoso 界面和 SKILL bridge 都不会响应。 如果需要处理很多函数,应在同一个 SSH/终端会话中完成一次 attach、批量修改和 detach,不要为每个函数重新 attach。 #### 调用 `ilMakeSym` 前先确认线程和停止点 后面的 `ilMakeSym(...)` 属于 GDB inferior call,也就是让被调试进程在暂停期间执行一段运行时函数。Virtuoso 是多线程程序;如果当前选中的线程不对,或者其他线程正持有 SKILL 运行时、分配器、GC、Context loader 的锁,inferior call 可能死锁或导致进程崩溃。 应先让专用 Virtuoso 会话处于 CIW/SKILL 空闲状态,再检查线程: ```gdb info threads thread apply all bt 6 ``` 根据线程名称和 backtrace 找到 SKILL 主执行线程,再显式选择并复核: ```gdb thread bt ``` 不要机械地假定 thread 1 一定正确。若不能确认主执行线程,或 backtrace 显示正在执行 `malloc`、GC、`loadContext`、用户回调或持锁路径,就不要从 GDB 调用 `ilMakeSym`;先 `detach`,让进程回到已知空闲点后重新安排一次受控 attach。 ### 11.6 在 GDB 中找到 Symbol 调用运行时自己的 `ilMakeSym`: ```gdb set $sym = (unsigned char *)ilMakeSym("someFunction") p/x $sym ``` 确认名称: ```gdb x/s *(char **)($sym + 0x08) ``` 预期应打印 `someFunction`。 如果 `$sym` 为 0,或者名称不正确,不要继续修改内存。 `ilMakeSym` 对不存在的名称可能创建一个新 Symbol,因此候选名必须先在 SKILL 中通过 `getd` 验证。仅仅看到 `$sym` 非 0,并不能证明 CXT 中原本存在该函数。 ### 11.7 找到 function binding 和真正的 FunObj 读取 Symbol `+0x20`: ```gdb set $def = *(unsigned char **)($sym + 0x20) p/x $def ``` 如果 `$def == 0`,说明没有普通函数 binding。应回到 Virtuoso 检查 CXT 是否成功加载以及函数名是否正确。 读取对象 type byte: ```gdb p/x *(unsigned char *)$def ``` 常见情况: - `0x01`:`$def` 已经是 FunObj; - `0x05`:`$def` 是一层 Symbol wrapper,需要再取它的 `+0x20`; - 其他值:不要按本文偏移强行修改。 设置 `$fun`: ```gdb set $fun = $def ``` 如果上一步 type 是 `0x05`,再执行: ```gdb set $fun = *(unsigned char **)($def + 0x20) ``` 最后再次验证: ```gdb p/x $fun p/x *(unsigned char *)$fun ``` 只有 type byte 为 `0x01` 时,才把它当作本文的 version 602 FunObj。 ### 11.8 检查并清除 read-protected 位 修改内存前,应先完成所有指针和类型检查。不要先改 Symbol/FunObj,再去解析 subtype-0 guard,否则 guard 异常时会留下“只改了一半”的对象状态。 先查看 Symbol 和 FunObj 前几个字节: ```gdb x/8bx $sym x/8bx $fun ``` 分别保存 flags byte: ```gdb set $old_sym_flags = *(unsigned char *)($sym + 0x02) set $old_fun_flags = *(unsigned char *)($fun + 0x02) p/x $old_sym_flags p/x $old_fun_flags ``` 当前版本中,下面任一条件都可能让 `pp` 判定为 read-protected: ```text $old_sym_flags & 0x02 $old_fun_flags & 0x80;普通 subtype 路径 ``` 接着检查 FunObj subtype: ```gdb set $subtype = *(unsigned char *)($fun + 0x01) & 7 p/d $subtype ``` 如果 `$subtype == 0`,当前版本的保护函数不会直接使用 `$fun` 的 flags,而会跟随 FunObj `+0x18`。先解析 guard,但仍然不要写内存: ```gdb set $guard = *(unsigned char **)($fun + 0x18) p/x $guard ``` 确认 `$guard` 非 0、低两位为 0,并用 `x` 验证地址确实可读: ```gdb x/8bx $guard set $old_guard_flags = *(unsigned char *)($guard + 0x02) p/x $old_guard_flags ``` 如果 `$guard` 无效、未对齐或 `x/8bx` 报 `Cannot access memory`,立即停止,不修改任何 flags。 只有 Symbol、FunObj,以及 subtype 0 时的 guard 全部验证完成以后,才统一写入: ```gdb set {unsigned char}($sym + 0x02) = $old_sym_flags & 0xfd if $subtype == 0 set {unsigned char}($guard + 0x02) = $old_guard_flags & 0x7f else set {unsigned char}($fun + 0x02) = $old_fun_flags & 0x7f end p/x *(unsigned char *)($sym + 0x02) p/x *(unsigned char *)($fun + 0x02) ``` 如果 subtype 为 0,还应另外查看: ```gdb p/x *(unsigned char *)($guard + 0x02) ``` `0xFD` 只清 bit `0x02`,`0x7F` 只清 bit `0x80`。不要把整个 byte 直接写成 0,因为同一个 byte 里可能还有其他有效 flags。 ### 11.9 让 Virtuoso 恢复运行 最简单方式是直接 detach: ```gdb detach quit ``` 这样 Virtuoso 立即继续运行,内存中的保护位保持为已清除,直到该对象被替换或进程退出。 如果必须在打印后恢复原 flags,可以让 GDB 保持 attach: 1. 执行 `continue`; 2. 通过 Virtuoso UI 或另一个已有的控制通道执行 `pp`; 3. 回到 GDB 按 `Ctrl-C` 暂停; 4. **先重新验证对象身份,再写回。** 进程继续运行期间,CXT 可能被重载,对象也可能被替换。不能直接假定旧 `$sym/$fun/$guard` 仍然有效。至少应重新检查: ```gdb p/x *(unsigned char *)$sym x/s *(char **)($sym + 0x08) set $current_def = *(unsigned char **)($sym + 0x20) p/x $current_def ``` 要求: - `$sym` 仍是 type 5,pname 仍是同一函数; - direct FunObj 情况下 `$current_def == $fun`; - wrapper 情况下,当前 wrapper 及其 `+0x20` 最终仍解析到原 `$fun`; - subtype 0 时,当前 `*(fun+0x18)` 仍等于原 `$guard`; - 所有地址仍可读。 只有全部相同,才按实际修改过的目标恢复: ```gdb set {unsigned char}($sym + 0x02) = (*(unsigned char *)($sym + 0x02) & 0xfd) | ($old_sym_flags & 0x02) if $subtype == 0 set {unsigned char}($guard + 0x02) = (*(unsigned char *)($guard + 0x02) & 0x7f) | ($old_guard_flags & 0x80) else set {unsigned char}($fun + 0x02) = (*(unsigned char *)($fun + 0x02) & 0x7f) | ($old_fun_flags & 0x80) end detach quit ``` 这里不能直接把整个 `$old_*_flags` byte 写回。`continue` 期间,同一 byte 中与恢复无关的其他 bit 可能已经被 Virtuoso 合法更新;上面的表达式只恢复本次清除的 `0x02` 或 `0x80`,同时保留当前 byte 的其他 bit。 若任一身份或指针发生变化,不要向旧地址写回。最稳妥的处理是结束这个专用恢复进程;进程退出后所有内存修改自然消失。一般情况下,直接 detach、完成导出后关闭专用 Virtuoso,比运行中恢复旧 flags 更简单可靠。 ### 11.10 使用 `pp` 输出到文件 在 Virtuoso SKILL 环境中: ```skill let((oldPo p) oldPo = poport p = outfile("/tmp/recovered.il") unless(outportp(p) && openportp(p) error("Cannot open /tmp/recovered.il for writing\n") ) unwindProtect( progn( poport = p pp(someFunction) ) progn( poport = oldPo when(outportp(p) && openportp(p) close(p) ) ) ) ) ``` 正常情况下,`/tmp/recovered.il` 中会出现反编译后的 SKILL 定义。 这里先检查 `outfile`,并使用 `unwindProtect` 保证即使 `pp` 报错或被中断,也会恢复 `poport` 并关闭输出端口。批量恢复时可以在受保护的 `progn` 内对每个 `pp` 使用 `errset`,避免一个函数失败后跳过整个列表的其余项目。 ### 11.11 多函数恢复 对多个函数,核心循环始终相同: ```text 函数名 -> ilMakeSym -> 验证 Symbol type 5 -> Symbol + 0x20 -> 解析可能的 wrapper -> 验证 FunObj type 1 -> 清除 Symbol + 0x02 的 0x02 -> 清除 FunObj/guard + 0x02 的 0x80 -> pp ``` 可以把 GDB 命令写成 command file 或自定义 GDB command,一次 attach 后处理一批函数,再统一 detach。不要为每个函数反复重启 Virtuoso或重新建立远程连接。 下面是一个仅适用于本文目标版本的、带基本类型检查的 GDB command 示例。候选函数名应先在 SKILL 中确认存在。这个宏只自动处理 `Symbol +0x20` 直接指向 type-1 FunObj 的常见情况;遇到 type-5 wrapper 会安全跳过,需要按 11.7 节先人工解析 wrapper: ```gdb define cxt_unprotect set $sym=(unsigned char*)ilMakeSym($arg0) set $can_patch=1 if $sym == 0 printf "SKIP: ilMakeSym returned NULL\n" set $can_patch=0 else if (((long)$sym & 3) != 0) || (*(unsigned char*)$sym != 5) printf "SKIP: not a type-5 Symbol, address=%p\n", $sym set $can_patch=0 else set $fun=*(unsigned char**)($sym+0x20) if $fun == 0 printf "SKIP: function binding is nil, symbol=%p\n", $sym set $can_patch=0 else if (((long)$fun & 3) != 0) || (*(unsigned char*)$fun != 1) printf "SKIP: binding is not a type-1 FunObj, binding=%p\n", $fun set $can_patch=0 else set $sym_before=(unsigned int)*(unsigned char*)($sym+2) set $fun_before=(unsigned int)*(unsigned char*)($fun+2) set $subtype=(unsigned int)(*(unsigned char*)($fun+1) & 7) if $subtype == 0 set $guard=*(unsigned char**)($fun+0x18) if ($guard != 0) && (((long)$guard & 3) == 0) # This read aborts the command before any write if memory is inaccessible. set $guard_before=(unsigned int)*(unsigned char*)($guard+2) else printf " WARN: subtype-0 guard is invalid: %p\n", $guard set $can_patch=0 end end if $can_patch set {unsigned char}($sym+2)=(*(unsigned char*)($sym+2) & 0xfd) if $subtype == 0 set {unsigned char}($guard+2)=(*(unsigned char*)($guard+2) & 0x7f) printf "OK: sym=%p flags=%02x->%02x guard=%p flags=%02x->%02x subtype=0\n", $sym, $sym_before, (unsigned int)*(unsigned char*)($sym+2), $guard, $guard_before, (unsigned int)*(unsigned char*)($guard+2) else set {unsigned char}($fun+2)=(*(unsigned char*)($fun+2) & 0x7f) printf "OK: sym=%p flags=%02x->%02x fun=%p flags=%02x->%02x subtype=%u\n", $sym, $sym_before, (unsigned int)*(unsigned char*)($sym+2), $fun, $fun_before, (unsigned int)*(unsigned char*)($fun+2), $subtype end end end end end end end ``` 使用: ```gdb cxt_unprotect "functionA" cxt_unprotect "functionB" cxt_unprotect "functionC" detach quit ``` 把宏和函数列表放进一个纯文本 `.gdb` 文件后,可以用 `-batch -x` 一次 attach 批量执行。这样即使有很多函数,也不需要为每个函数单独 attach。 批处理前必须先在同一专用 Virtuoso 进程上手工验证安全线程和一个函数。`-batch` 不会自动判断哪个线程适合执行 `ilMakeSym`;若线程编号可能变化,应从一开始在 GDB 控制下启动专用进程或使用已验证的安全停止点,不要把该宏直接用于生产会话。 函数名可以通过加载前后 oblist/function binding 差分获得,不需要依赖固定前缀。但要注意: - 新增 Symbol 容易发现; - 已有 Symbol 的 function binding 被覆盖时,也要比较 `ilGetd` 前后值; - defmethod body 可能没有普通全局函数名,必须结合 OOP 安装记录; - 只扫描 CXT 中的可打印字符串可能漏项或产生误报。 ### 11.12 GDB 方案恢复 class、generic 和 method 的边界 清保护位只解决“允许 `pp` 读取 FunObj”这一件事,不负责自动发现和重建所有 OOP 定义: - 普通 `procedure`:清对应 Symbol/FunObj 后,可以直接 `pp(functionName)`; - `defgeneric`:`pp(genericName)` 通常只能得到 generic 声明; - `defmethod`:需要先从 generic method table 获得 specializers、role 和 method FunObj,把 method FunObj 临时绑定到一个 Symbol,清临时 Symbol/FunObj 的保护,再 `pp` 方法体,最后重建 `defmethod` 头; - `defclass`:主要依靠 class metadata、superclass 和 slot specs 重建,单纯清 FunObj 保护位并不足够; - 纯变量 CXT:如果没有 FunObj/VCODE,就不走 `pp` 路径,应读取 Symbol `+0x18` 的 value binding。 所以完整 GDB/Virtuoso 自动化通常是:先发现 class/generic/method/procedure,再一次 attach 批量解保护,最后分别用 metadata、method table 和 `pp` 生成输出。 ### 11.13 GDB 方案常见故障 #### `ptrace: Operation not permitted` 可能原因: - 当前用户不是 Virtuoso 进程所有者; - Linux `ptrace_scope` 限制; - 容器或安全策略禁止 attach。 优先使用进程所有者或已有 `sudo` 权限。不要在不清楚服务器安全策略时永久放宽系统配置;如临时调整,完成后恢复原值。 如果 `sudo gdb` 仍受 Yama 限制,而且确实获得了服务器管理授权,可以临时保存并恢复配置: ```sh old_scope=$(cat /proc/sys/kernel/yama/ptrace_scope) || exit 1 restore_ptrace_scope() { sudo sysctl -w "kernel.yama.ptrace_scope=$old_scope" >/dev/null } trap restore_ptrace_scope EXIT HUP INT TERM sudo sysctl -w kernel.yama.ptrace_scope=0 || exit 1 # 在同一个 shell 中执行本次 GDB 操作。 # 正常退出、SSH HUP 或 Ctrl-C 时,trap 都会尝试恢复原值。 sudo gdb -q -nx -p # 完成后立即显式恢复。只有写回且复读确认成功,才取消 trap。 if restore_ptrace_scope && \ current_scope=$(cat /proc/sys/kernel/yama/ptrace_scope) && \ [ "$current_scope" = "$old_scope" ]; then trap - EXIT HUP INT TERM else echo "未能确认 ptrace_scope 已恢复;保留退出 trap 并停止。" >&2 exit 1 fi ``` 通常不需要修改该设置;优先尝试同一用户和 `sudo gdb`。`trap` 不能处理 `SIGKILL` 或主机掉电,所以即使采用该方法,也应在结束后再次读取 `/proc/sys/kernel/yama/ptrace_scope` 确认已经恢复。 #### `ilMakeSym` 无法调用 检查: - GDB 是否 attach 到真正加载 `*.so` 的进程; - 动态符号是否可见; - CXT 是否已经加载; - 当前线程是否处于可以安全调用运行时函数的位置。 如果在不稳定停止点调用函数导致问题,可以先让进程停在 SKILL 空闲状态再 attach。 #### `$def` 为 0 通常表示: - 函数名错误; - CXT 没有加载成功; - 该 Symbol 只有普通变量值,没有函数定义; - 它是 method body 或其他非普通全局定义。 #### FunObj type 不是 1 不要直接写 `$def + 2`。先判断是否存在 type-5 wrapper;若仍不是 type 1,应重新分析该版本的对象布局。 #### 清除以后 `pp` 仍然拒绝 可能原因: - 修改的不是最终 FunObj; - CXT 重新加载后替换了对象; - 没有清除 Symbol `+0x02` 的 bit `0x02`; - subtype 为 0,但没有处理 `fun+0x18` 指向对象的 bit `0x80`; - `pp` 面对的是 native/binary,而不是 VCODE; - 函数是 closure,需要先取得内部 FunObj。 #### 修改后 Virtuoso 崩溃 最常见原因是: - 使用了错误版本的偏移; - `$def` 为 nil 或不是有效对象; - 把整个 flags byte 清零; - 在运行时正在修改对象时强行写内存。 重新启动进程即可恢复磁盘上未修改的官方库。重要工作应在 attach 前保存。 --- ## 12. 方案三:C 程序直接调用 `*.so` 这是本次实现的脱离 Virtuoso 方案。它在独立 Linux x86-64 进程中加载匹配版本的 `*.so`,直接复用官方 Context loader 和 VCODE decompiler,因此不启动 Virtuoso、不调用 `pp`,也不经过 `ilPp1` 的保护门禁。 ```mermaid flowchart TD A["C 程序启动"] --> B["dlopen *.so,RTLD_NOW | RTLD_GLOBAL"] B --> C["ilInitLisp 初始化最小 SKILL 运行时"] C --> D["记录加载前 oblist/function/class binding"] D --> E["ilLoadContext 加载、重定位并安装 CXT"] E --> F["记录 OOP method 安装信息和加载后 binding"] F --> G["前后差分,无前缀发现定义"] G --> H["iliVcodeDecomp 生成 AST"] H --> I["打印并重建 class/generic/method/procedure"] ``` 因为 `*.so` 原本依赖完整 Cadence 宿主,独立程序还需要一个最小 ABI 兼容层补齐内存、page map、hash、路径和诊断类外部符号。自动模式通过加载前后 oblist/function/class binding 差分发现名称,并通过 `iliInstallOOPExecute` 记录取得 method 的 generic、specializers、FunObj 和 role,所以不要求函数名前缀。 经验证,该方法可以支持 `procedure/nprocedure/mprocedure`、`defclass`、`defgeneric` 和 `defmethod`等恢复。 纯变量 CXT 会被报告为没有可恢复定义。native/`binary` 函数同样没有 VCODE 可供恢复。由于 `ilLoadContext` 可能执行 TopForm、用户类型回调和初始化逻辑,不可信 CXT 应放在隔离的专用进程或虚拟机中处理。 --- ## 13. 三种方案的本质关系 三种方案表面不同,底层都围绕同一条能力链: ```text CXT object graph -> FunObj -> VCODE -> Cadence 官方 VCODE decompiler -> AST/form -> SKILL 文本 ``` 差别只在于怎样到达 decompiler: - 修改 `.so`:永久改变 `pp` 的保护分支; - GDB:临时改变当前 Symbol/FunObj 的保护状态,让原版 `pp` 继续; - C 直调:绕过 `pp` 门禁,直接调用底层加载和反编译函数。 因此,“保护位”本身不是恢复算法。真正完成代码重建的是: - CXT loader; - pointer relocation; - Symbol/class/method discovery; - VCODE decompiler; - AST printer。 --- ## 14. 能恢复什么,不能恢复什么 ### 14.1 通常可以恢复 - VCODE procedure 的主要控制流和表达式; - 参数列表、required/optional 参数; - 很多 literal 和字符串; - class 名、父类和 slot specs; - generic 参数结构; - method specializers 和 role; - 普通变量的最终值,前提是实现对应对象序列化; - TopForm 中仍保留的逻辑 form,前提是在执行释放前捕获。 ### 14.2 通常无法原样恢复 - 原始注释; - 原始缩进和换行; - 宏展开前的精确文本; - 编译优化前的冗余表达式; - 普通 snapshot 变量的原始 RHS; - 已经丢失的局部调试符号; - native/binary 函数的 SKILL 源码; - CXT 未包含、只依赖外部环境的定义。 ### 14.3 版本和依赖风险 恢复失败不一定表示 CXT 损坏。常见原因还包括: - CXT 与 `*.so` 版本不匹配; - 32/64 位不匹配; - 缺少基础 Context; - 依赖的系统库缺失; - 用户类型 load callback 依赖完整 Virtuoso 服务; - 保存时引用了当前最小运行时中不存在的旧页; - 私有 ABI 在另一个版本中发生变化。 --- ## 15. 面向初学者的常见问题 ### 15.1 CXT 里面有函数名字符串吗? 函数名来自 type-5 Symbol 的 pname。pname 字节位于 tail string blob,Symbol `+0x08` 指向它。 但是,直接扫描字符串不能可靠确定哪些字符串一定是函数名。更可靠的方法是加载 CXT 后遍历 oblist,并检查 Symbol 的 function binding。 ### 15.2 为什么不需要函数名前缀? 因为可以比较加载前后的完整 Symbol/function binding,而不是只搜索满足某个前缀的字符串。 ### 15.3 read-protected 是不是把 VCODE 加密了? 不是。当前分析中它主要是一个打印/反编译门禁标志。VCODE 和 literal 仍在对象图中。 ### 15.4 清除保护位为什么 `pp` 就能工作? 因为 `pp` 原本已经拥有 VCODE decompiler,只是在进入它之前调用了保护判断。清除对应位后,控制流继续进入官方反编译函数。 ### 15.5 `defclass` 是否也有一段 VCODE 源码? 不能这样理解。class 主要保存为 class metadata、slot specs、superclass 和 Symbol property。恢复时需要根据 metadata 重建 `defclass` form。 ### 15.6 `defmethod` 为什么比 procedure 难? method body 虽然也是 FunObj/VCODE,但 method 属于哪个 generic、specializer 是什么、是 before/after/around 还是 primary,都不一定包含在一个普通函数名里。这些信息要从 OOP 安装记录和 method table 获得。 ### 15.7 CXT 只有变量时有没有 VCODE? 如果变量只保存整数、字符串、List、Array 等普通数据,普通 value binding 本身不需要 FunObj/VCODE。 如果变量本身绑定到 closure/FunObj,就会出现相应 VCODE。TopForm 保存的是可重放的 form/AST 和执行上下文,它本身并不必然是 type-1 FunObj;是否还包含 VCODE,取决于 TopForm 引用或定义的具体内容。 ### 15.8 为什么输出代码与原代码括号或空格不同? 因为恢复器打印的是反编译后的 AST/form,不是复制原始文本。只要 token 和语义一致,排版差异通常不是恢复错误。 ### 15.9 Windows能直接运行 Linux 的 `*.so` 吗? 不能原生 `dlopen`。本文分析库是 Linux x86-64 ELF。独立 C 方案应在 Linux x86-64 服务器、虚拟机或兼容容器中运行。 --- ## 16. 当前版本关键函数索引 本表是基于Virtuoso 618版本: | 函数 | 地址 | 作用 | | ----------------------- | --------: | ---------------------------------- | | `iliSaveContext` | `0x6E360` | 保存 Context 主流程 | | `ilSaveContext` | `0x70F50` | 导出的保存入口 | | `iliLoadContext` | `0x70FE0` | 加载 Context 主流程 | | `ilLoadContext` | `0x73E40` | 导出的加载入口 | | `ilXDRBlock` | `0x6D5F0` | 按 blockType 读取/转换 cells | | `ilXDRCntxtHdr` | `0x6DC30` | Context header 转换 | | `ilAdjustPointers1` | `0x67930` | 旧地址到新地址的核心重定位 | | `ilMakeSym` | `0xEDEB0` | intern/查找 Symbol | | `ilGetd` | `0xEBAC0` | 读取 function binding | | `ilGetSym` | `0xF29F0` | 读取普通变量 value binding | | `ilPp1` | `0xDC710` | `pp` 主要实现路径 | | `iliFunIsReadProtected` | `0xF9430` | 检查函数 read-protected | | `iliVcodeDecomp` | `0xB2000` | VCODE 到 form/AST | | `iliVcodeDecompFun` | `0xB4BD0` | FunObj 反编译路径 | | `iliInstallOOPExecute` | `0x6AF80` | 安装 class/generic/method OOP 记录 | | `iliTopFormLoadContext` | `0xF24A0` | 载入 TopForm | | `iliExecuteTopForms` | `0xF5040` | 按顺序执行 TopForm | --- ## 17. 实际选择建议 | 目标 | 建议方案 | | ------------------------------------- | ---------------------------------------------------------- | | 已有正常 Virtuoso,只恢复少量已知函数 | GDB 内存修改 +`pp` | | 不允许修改安装目录 | GDB 或 C 直调,不采用磁盘 patch | | 需要自动发现所有函数,不想提供前缀 | C 直调 + oblist 前后差分 | | 需要批量处理大量 CXT | C 直调 | | 需要快速验证某一个保护位判断 | GDB | | 需要 defclass/defmethod 的完整结构 | 优先使用可以实现 class/OOP 记录处理的 C 直调方案 | | 需要恢复普通变量最终值 | 在 C 直调方案中增加`ilGetSym` snapshot 和对象序列化 | | 需要恢复 TopForm 原逻辑 | 在`iliTopFormLoadContext` 到 `iliExecuteTopForms` 之间截获 | 总体上: - 修改磁盘 `.so` 的传统方案风险最高,只适合受控研究环境; - GDB 方案最适合已有 Virtuoso 的单次恢复和验证; - C 直调方案工程量最大,但完成后最适合自动化、无前缀发现和批量恢复。 --- ## 18. 总结 CXT 的核心不是“加密后的源码文本”,而是: ```text 可变长 Header + 多个对象 Block + String / Qword / User-type Tail + 保存地址到加载地址的重定位信息 + Symbol、FunObj、VCODE、class 和 method 对象图 ``` 恢复代码的关键也不是单纯清除一个保护位,而是完整利用: ```text 官方 Context loader + Symbol 和 OOP 定义发现 + 官方 VCODE decompiler + AST/form printer ``` 保护位只决定普通 `pp` 是否愿意进入反编译路径。传统 `.so` patch 和 GDB 内存修改都是让 `pp` 继续执行;独立 C 方案则直接调用保护门禁之后的底层能力。 理解这一点以后,CXT 的文件布局、变量存储、VCODE 位置以及三种恢复方式就能够统一到同一套运行时对象模型中。 Loading... # Virtuoso CXT 文件结构、生成原理与三种恢复方案 ## 1. 文档范围 本文介绍以下内容: 1. CXT 文件是什么,以及它与普通 SKILL 源文件有什么区别。 2. CXT 是怎样从 SKILL/SKILL++ 代码生成的。 3. CXT 文件在磁盘上的整体组织结构。 4. `procedure`、普通变量、`defclass`、`defgeneric` 和 `defmethod` 在 CXT 中如何表示。 5. CXT 加载时,保存地址如何被重定位成当前进程中的有效地址。 6. 为什么受保护的 VCODE 仍然可以被恢复。 7. 三种恢复方案: - 传统方案:修改 Virtuoso 的 `*.so`,让 `pp` 忽略保护判断。 - 创新方案一:使用 GDB 修改正在运行的 Virtuoso 内存,再使用 `pp`。 - 创新方案二:用 C 程序直接加载 `*.so`,脱离 Virtuoso 和 `pp`。 本文不展开 CXT 外层的简单 XOR 处理。这里关注的是完成外层处理之后,CXT 内部真正有用的对象图、VCODE、类和方法数据。 ### 1.1 版本边界 文中的精确偏移来自Virtuoso 618版本运行库 不同 Cadence/Virtuoso 版本可能改变结构大小、字段含义或内部函数地址。因此应把本文理解为: - 对当前分析版本已经验证的结构说明; - 分析其他版本时的可靠方法; - 不是对所有历史版本都保证不变的公开 ABI 文档。 例如,version 602 的 FunObj 是 40 字节,而 version 601 的旧 FunObj 路径使用 32 字节布局。 --- ## 2. 先建立一个正确直觉:CXT 不是源代码压缩包 初学者最容易产生的误解是: > “CXT 可能只是把 `.il` 源文件压缩或加密后放进去,解开以后就能拿回原文。” 实际情况不是这样。 CXT 更接近一份“SKILL 运行时对象图快照”。源代码被读取、求值或编译以后,运行时会形成很多对象: - Symbol,也就是变量名和函数名; - 普通变量的最终值; - List/Cons; - String; - Array; - FunObj; - VCODE; - class metadata; - generic method table; - method 安装记录; - TopForm; - namespace 和导出信息。 CXT 保存的是这些运行时对象及其相互引用,而不是原始文本。 可以把两者类比为: ```text SKILL 源文件 类似“建造说明书” CXT 类似“建造完成后的零件、连接关系和部分可执行程序” ``` 因此,恢复出来的代码通常可以在语义上接近原始代码,但以下内容往往已经不可逆地丢失: - 注释; - 空格和换行风格; - 原始括号排版; - 局部变量原始命名的一部分; - 某些宏展开前的写法; - 被优化掉的中间表达式; - 普通变量赋值时原始 RHS 的计算过程。 例如,普通快照路径下: ```skill foo = 1 + 2 ``` 和: ```skill foo = 3 ``` 最终都可能只留下“Symbol `foo` 的值为整数 3”。仅根据最终 binding 无法判断原来是哪一种写法。 --- ## 3. 阅读本文前需要认识的几个术语 ### 3.1 Symbol Symbol 是 SKILL 运行时里的“名字对象”。 同一个 Symbol 可以同时关联: - 普通变量值; - 函数定义; - property list; - namespace; - 保护标志。 因此,变量名和函数名不是两种完全不同的对象。它们都可以从 Symbol 开始,只是使用的 binding 槽不同。 ### 3.2 Binding Binding 可以理解为“某个名字当前绑定到什么对象”。 在当前 64 位版本中: - Symbol `+0x18` 是普通变量值 binding; - Symbol `+0x20` 是函数定义 binding。 这也是下面两个函数的区别: ```text ilGetSym(symbol) -> 读取 symbol + 0x18,普通变量值 ilGetd(symbol) -> 读取 symbol + 0x20,函数定义 ``` ### 3.3 ILValue ILValue 是 SKILL 运行时中常见的 64 位值表示。它既可能直接保存一个小整数,也可能保存指向对象的指针。 常见低位标记如下: | 形式 | 含义 | | ---------------- | ---------------------------- | | `0` | `nil` | | `value & 1 != 0` | 立即数整数,通常为 `(n << 2) | | `value & 2 != 0` | List/Cons 引用 | | 低两位为`00` | 对齐后的堆对象指针 | 例如: ```text 整数 1 -> (1 << 2) | 1 = 0x5 整数 123 -> (123 << 2) | 1 = 0x1ED ``` 因此,小整数不一定需要单独的 integer object。 ### 3.4 FunObj FunObj 是 SKILL 可执行函数对象。对于编译后的 SKILL 函数,它会关联: - 参数数量和参数描述; - VCODE 向量; - 指令入口; - 函数子类型; - 保护标志。 ### 3.5 VCODE VCODE 是 SKILL 运行时使用的 64 位 qword 混合指令流。它不是字符串,也不是原始源码。 VCODE 中可以混合出现: - 指令 word; - 立即数; - Symbol、String、List 等对象引用; - 默认参数和 literal; - 尾部 literal pool。 ### 3.6 `pp` `pp` 是 Virtuoso/SKILL 环境中的 pretty printer。对于 VCODE 函数,它并不只是打印内存,而是会调用官方 VCODE 反编译逻辑,把 FunObj 转换回可打印的 SKILL form。 ### 3.7 oblist oblist 是运行时已 intern 的 Symbol 列表。通过比较加载 CXT 前后的 oblist 及其 binding,可以自动发现 CXT 新增或改写的函数和类,而不需要猜测函数名前缀。 --- ## 4. CXT 的生成原理 ### 4.1 总体流程 下面的流程图展示了从源代码到 CXT 的主要阶段: ```mermaid flowchart TD A["SKILL / SKILL++ 源文件"] --> B["setContext:进入 Context 构建期"] B --> C{"顶层内容采用哪条路径"} C -->|"普通 load / eval"| D["立即执行赋值并编译定义"] C -->|"TopForm 包装"| E["保存待加载时执行的 form 和顺序号"] D --> F["形成运行时对象图"] E --> F F --> G["确定 Context 自有页面和可达对象"] G --> H["复制 Symbol、List、FunObj、类和方法数据"] H --> I["收集字符串到 string blob"] H --> J["收集 Array / VCODE 到 qword vector"] H --> K["序列化用户类型、namespace 和导出信息"] I --> L["写入可变长 CxtHeader"] J --> L K --> L L --> M["写入 BlockHeader 和 object cells"] M --> N["写入 tail 数据"] N --> O["生成 CXT 文件"] ``` ### 4.2 `setContext` 的作用 `setContext` 不只是设置一个字符串名称。它会让运行时进入 Context 构建状态,并区分: - 构建 Context 之前已经存在的基础对象; - 构建期间新建或导出的对象; - 当前 Context 依赖的旧对象; - 需要写进 CXT 的对象页面和尾部数据。 这样生成的 CXT 不需要把整个 Virtuoso 进程内存全部保存,只需要保存当前 Context 的对象及其必要引用。 ### 4.3 普通求值/快照路径 普通路径大致是: ```text setContext -> load/eval 源文件 -> procedure 被编译成 FunObj/VCODE -> 普通赋值立即写入 Symbol value binding -> class/generic/method 在运行时安装 -> saveContext 保存最终对象图 ``` 例如: ```skill foo = list(1 "x") ``` 赋值执行以后,`ilSet1` 会把最终 List 对象写入 `foo` Symbol 的 `+0x18`。保存时 CXT 会沿着这个引用继续保存 List cell、String descriptor 和字符串内容。 这条路径保存的是结果,不保存原始表达式。若 RHS 中包含时间、随机数或外部查询,通常保存的是制作 CXT 当时得到的结果。 ### 4.4 TopForm 路径 CXT 还支持 `topContextSkillForm` 用户类型。 只有上层编译/打包流程显式调用 `_makeTopSkillForms`、`ilfMakeSkillTopForm` 或 `loadTopForm` 一类机制时,顶层 form 才会以 TopForm 方式保存。`saveContext` 本身不会把所有普通赋值自动转换成 TopForm。 当前版本中,一个 TopForm **运行时对象**约为 0x60 字节,其中重要字段是: | 偏移 | 含义 | | --------------- | ------------------------- | | `+0x08` | 待执行的 ILValue form/AST | | `+0x10...+0x50` | 执行环境和模式相关状态 | | `+0x58` | 顺序号 | 加载 CXT 时,`iliTopFormLoadContext` 会把 TopForm 按顺序号放入表中。普通对象和 Symbol 完成重定位以后,`iliExecuteTopForms` 再按顺序调用 `iliExecSkillForms`。 这里的 0x60 是内存对象大小,不代表磁盘上直接平铺一个 0x60 字节裸结构。`iliTopFormSaveContext` 会把 10 个 ILValue 字段和顺序号编码成 6 组 16 字节的 user-type pairs,再通过 CXT 的 User-type vector 保存;加载回调负责还原为运行时记录。 这意味着: - 普通快照路径:RHS 在制作 CXT 时执行,加载时恢复结果; - TopForm 路径:RHS 在每次加载 CXT 时重新执行; - 两种记录可以因打包流程而同时出现; - 如果两者同时存在,TopForm 后执行,可能覆盖前面恢复的 snapshot binding。 所以,看到“CXT 中只有变量赋值”时,不能只根据源码形式断言它一定采用哪条路径。应检查实际 CXT 是否包含 `topContextSkillForm` 用户类型记录。 --- ## 5. CXT 文件的总体组织结构 ### 5.1 从文件头到文件尾 ```mermaid flowchart TB A["可变长 CxtHeader"] --> B["Block 0:32-byte BlockHeader + object cells"] B --> C["Block 1:32-byte BlockHeader + object cells"] C --> D["更多对象 Block"] D --> E["Tail 起点"] E --> F["String blob:名称和字符串字节"] F --> G["Qword vector:Array 元素、VCODE 等"] G --> H["User-type vector"] H --> I["可选 Namespace / ZIP vector / 其他兼容数据"] ``` 可以把文件分为三层: 1. **CxtHeader**:描述版本、大小、tail、保存时基址和 block 数量。 2. **对象 Block**:保存固定大小的运行时 object cells。 3. **Tail**:保存不适合直接放在固定 cell 中的变长数据。 ### 5.2 已确认的 CxtHeader 关键字段 下面的偏移针对本文分析的 64 位 version 602 格式: | 偏移 | 含义 | | --------------- | ---------------------------------------------------------------------- | | `+0x00...+0x03` | 平台/格式标识;当前 64 位保存端写入`00 00 32 00`,加载器要求首字节为 0 | | `+0x08` | version 602 中 Tail/string blob 起点相对当前 CXT record 起点的文件偏移 | | `+0x10` | 64 位字节序指纹 | | `+0x18` | 32 位字节序指纹 | | `+0x1C` | 新格式/可选 ZIP vector 相关标志;不能笼统称为“加密位” | | `+0x20` | CXT 格式版本,当前已确认 601/602 路径 | | `+0x28` | 版本标识字符串的保存时引用;字符串内容进入 string blob | | `+0x30` | Header 总长度,常见关系为`0x98 + 8 * blockCount` | | `+0x38` | 保存过程累计的 block 边界/尺寸类字段,具体语义与版本相关 | | `+0x40` | 保存时的`unbound` 哨兵值/引用 | | `+0x48` | namespace 区字节数;当前常见保存路径为 0,加载器仍支持非 0 | | `+0x50` | 可选 ZIP vector 的保存基址/指针字段 | | `+0x58` | 可选 ZIP vector 字节数 | | `+0x60` | Array/qword vector 区字节数 | | `+0x68` | 保存时 Array/qword vector 的运行时基址 | | `+0x70` | string blob 字节数 | | `+0x78` | 保存时 string blob 的运行时基址 | | `+0x80` | User-type vector 保存时基址 | | `+0x88` | User-type vector 的 pair 数量;对应磁盘数据通常为`16 * pairCount` 字节 | | `+0x90` | blockCount | | `+0x98...` | block 旧页地址表;固定头已经容纳第一项,后续项构成可变扩展 | Header 里的“保存时基址”非常重要。CXT 中大量字段保留的是保存进程中的地址形态,而不是简单的文件偏移。加载器依靠这些基址和旧页地址表完成重定位。 version 602 的 header 会先处理固定 `0xA0` 字节。当 `blockCount = N >= 1` 时,还会追加 `8 * (N - 1)` 字节旧页地址,因此: ```text headerSize = 0xA0 + 8 * (N - 1) = 0x98 + 8 * N ``` `+0x08` 的 tailOffset 也有明确版本边界:version 602 保存端会在所有对象 block 写完后回填它;version 601 的兼容路径可能把 `+0x04...+0x0F` 用作短 Context 名,不应直接套用 version 602 的解释。 version 602 中可以用两种方式得到 tail 起点: ```text 首选: tailStart = recordStart + header.tailOffset 离线校验: tailStart = recordStart + headerSize + Σ(0x20-byte BlockHeader + block.usedBytes) ``` 正常 block 的 `usedBytes` 应为 `cellSize` 的整数倍。若解析不可信文件,仍应先检查整除关系和文件边界,不能直接信任 header 中的长度。 ### 5.3 BlockHeader 每个对象 block 前面有一个 32 字节 BlockHeader: | 偏移 | 类型 | 含义 | | ------- | -------- | ------------------------------------------ | | `+0x00` | `uint16` | cellSize | | `+0x02` | `uint16` | blockType | | `+0x04` | `uint16` | usedBytes | | `+0x06` | `uint16` | flags,已确认`0x04` 表示 static page | | `+0x0C` | - | 调试名称/描述区域,内容依版本和 block 而定 | | `+0x18` | `uint64` | 页级 bookkeeping/link | | `+0x20` | - | object cells 起点 | 同一个 block 中的 cell 大小固定,由 `cellSize` 指定。加载器根据 `blockType` 选择对应的 XDR/字节序转换函数和指针调整逻辑。 ### 5.4 主要 blockType | blockType | 主要对象 | 常见 cell 大小 | 说明 | | --------: | ------------------ | ------------------------: | ----------------------------------------------- | | 1 | FunObj | 40 字节,旧版可能 32 字节 | procedure、generic、method body、class 相关对象 | | 2 | List/Cons | 24 字节 | car/cdr 对象图 | | 3/4 | boxed/兼容整数对象 | 16 字节 | 普通小整数通常直接使用 tagged ILValue | | 5 | Symbol | 56 字节 | 名称、变量值、函数定义、plist、namespace | | 6 | StdObj | 24 字节 | 标准对象数据 | | 7 | Environment | 24 字节 | 闭包/环境相关对象 | | 8 | Float | 16 字节 | double 数据通常位于`+0x08` | | 9 | String descriptor | 16 字节 | 字符内容位于 tail string blob | | 11 | Array descriptor | 24 字节 | 元素向量位于 tail qword vector | | 12 | Other | 16 字节 | 其他内部对象 | | 20...219 | User type | 由注册类型决定 | TopForm、表和扩展用户类型等 | 这张表只列出恢复 CXT 时最常见的类型。内部还存在垃圾回收、转发或兼容用途的特殊类型,不应仅凭一个字节就盲目修改。 ### 5.5 Tail 的作用 固定大小 cell 适合保存结构描述,但不适合直接内嵌任意长度的字符串、数组或 VCODE。因此 CXT 把变长内容集中放在 tail。 #### String blob String blob 主要保存: - Symbol 的 pname; - String 对象的字符数据; - 其他需要 C 字符串的元数据。 Symbol 的 `+0x08` 直接指向 pname 字节;String 对象则先经过 type-9 descriptor,再指向字符数据。 #### Qword vector Qword vector 主要保存: - Array 元素; - FunObj 的 VCODE; - literal 引用向量; - 其他 64 位槽数组。 因此,“qword vector 区”不能简单等同于“VCODE 区”。即使 CXT 没有任何 procedure,只要存在 Array,它仍可能有 qword vector 数据。 #### User-type 和可选兼容数据 注册用户类型可以通过自己的 save/load callback 保存变长内容。TopForm 就通过用户类型回调保存 form 和执行状态。 当前 version 602 主保存路径中,核心 tail 顺序已经确认是: ```text string blob -> Array/qword vector -> User-type vector -> 可选的 namespace/ZIP vector/兼容扩展数据 ``` 加载器保留 namespace 数据读取能力,但当前分析的常见保存路径会把相应计数初始化为 0,因此不能假定每个 CXT 都实际包含 namespace payload。可选 ZIP vector 也只有相关 header 字段非零时才存在。 --- ## 6. 最关键的对象结构 ### 6.1 Symbol:变量名和函数名的共同入口 当前版本的 type-5 Symbol 固定为 56 字节: | 偏移 | 含义 | | ------- | ------------------------------------------------------------------------------ | | `+0x00` | 对象头,byte 0 为 type 5 | | `+0x01` | Symbol 状态/子类型 flags | | `+0x02` | 函数侧 flags 和保护相关状态;已确认 bit`0x02` 会参与 `pp` 的 read-protect 判断 | | `+0x03` | 变量侧 flags;bit`0x01` 与变量写保护有关,bit `0x08` 与 guard 有关 | | `+0x08` | pname 指针 | | `+0x10` | property list | | `+0x18` | 普通变量 value binding | | `+0x20` | function binding | | `+0x28` | 全局 Symbol hash bucket collision 链 | | `+0x30` | namespace | `ilXDRSymbol` 会对 `+0x08`、`+0x10`、`+0x18`、`+0x20`、`+0x28` 和 `+0x30` 六个 qword 做格式转换,说明这些字段都属于实际序列化的 Symbol cell。 加载时不会简单保留 CXT 中的重复 Symbol。`ilAddCntxtSym`/`ilAddCntxtSymNs` 会按照 pname 和 namespace: 1. 查找当前运行时是否已有同名 Symbol; 2. 已存在则合并; 3. 不存在则创建并加入 oblist; 4. 重定位 plist/value/function; 5. 重新建立 Symbol hash 链。 ### 6.2 只有“变量名 = 值”时怎样存储 假设源文件只有: ```skill foo = list(1 "x") ``` 普通快照路径的对象图可以简化为: ```text type-5 Symbol "foo" +0x08 -> tail string blob 中的 "foo\0" +0x18 -> type-2 Cons #1 +0x20 -> 空函数 binding type-2 Cons #1 car = tagged integer 1,也就是 0x5 cdr -> type-2 Cons #2 type-2 Cons #2 car -> type-9 String descriptor cdr = nil type-9 String descriptor +0x08 -> tail string blob 中的 "x\0" ``` 需要特别区分: - `nil` 是合法值 0; - 未绑定变量使用 `ilcUnbound` 特殊对象; - 二者不是一回事。 对象之间依靠引用连接,而不是被拍平成文本。因此共享引用和循环引用在支持的对象类型中可以被保留。 如果变量的值本身是 FunObj、closure 或保存了函数对象,即使没有写命名 `procedure`,CXT 仍可能包含 FunObj/VCODE。 ### 6.3 version 602 的 FunObj 当前版本的 FunObj 为 40 字节。已确认的关键字段如下: | 偏移 | 含义 | | ------- | ---------------------------------------------------------------------------------------- | | `+0x00` | byte,type = 1 | | `+0x01` | FunObj subtype 和调用相关标志;低 3 位为 0 时是特殊 wrapper/closure 路径,字段解释会变化 | | `+0x02` | flags;bit`0x80` 是 VCODE read-protected | | `+0x03` | optional/formal 参数相关信息 | | `+0x04` | `uint16`,required 参数数 | | `+0x08` | `uint64`,VSize,即总 qword 数 | | `+0x10` | 调试符号或附属引用,具体语义依版本/subtype 而定 | | `+0x18` | VCODE qword vector 基址 | | `+0x20` | 可执行指令入口 | 上表的 VCODE 边界解释适用于普通、非 wrapper 的 VCODE FunObj。若低 3 位 subtype 为 0,应先沿内部引用找到真正受保护/可执行对象,不能直接把所有字段都按普通函数解释。 VCODE 的边界可以按下面方式理解: ```text base = *(fun + 0x18) entry = *(fun + 0x20) end = base + 8 * *(fun + 0x08) startIndex = (entry - base) / 8 ``` 逻辑区域是: ```text [base, entry) 参数、默认值、literal 或其他前缀数据 [entry, FunEnd) 主要指令流 [FunEnd, end) 可选 trailing literal pool ``` ### 6.4 VCODE qword 如何区分指令和 literal VCODE 是 64 位 word 流。当前版本中: ```text word bit 0 = 1 -> 指令 word word bit 0 = 0 -> literal / ILValue 对象引用 ``` 字符串不会直接内嵌成一段 VCODE 文本。典型引用关系是: ```text VCODE literal -> type-9 String descriptor -> descriptor + 0x08 -> CXT tail string blob ``` VCODE 在文件中的概念位置可以这样换算: ```text tailStart = recordStart + header.tailOffset vectorFileStart = tailStart + stringBytes vcodeFileOffset = vectorFileStart + (fun.vcodeBase - header.savedArrayBase) 其中 header.savedArrayBase 就是 Header `+0x68` 保存的旧 Array/qword-vector 基址。 ``` 但是,CXT 还存在旧页地址、tagged ILValue、基础 Context 引用和用户类型。实际恢复时,让官方加载器先完成重定位,再读取运行时 FunObj,通常比纯手工解析文件偏移更可靠。 ### 6.5 各种定义如何挂接 #### procedure / nprocedure / mprocedure 典型关系: ```text type-5 Symbol +0x20 function binding -> type-1 FunObj -> VCODE qword vector ``` 函数名来自 Symbol `+0x08` 的 pname,而不是 VCODE 本身。 #### defgeneric generic 仍以 FunObj 为核心。当前版本中: ```text (funObj[1] & 7) == 4 ``` 可用于识别 generic subtype。generic 还关联 method table。 #### defclass class 不等同于一段特殊“defclass 源码字符串”。它主要由: - class object; - class name; - superclass; - slot specs; - Symbol 的 class property; - 相关 FunObj subtype/metadata; 共同表示。 当前分析版本中 class 相关 FunObj subtype 满足: ```text (funObj[1] & 7) == 5 ``` 恢复 `defclass` 时应读取 class metadata,再重建父类和 slot 列表,而不是在 CXT 中搜索 `defclass(` 字符串。 #### defmethod method body 本身仍是普通 FunObj/VCODE。但是 method 的完整语义还需要: - 它属于哪个 generic; - specializer 列表; - primary/before/after/around role; - method FunObj。 这些信息位于 OOP 安装记录中。当前版本观察到的主要形式是: ```text (opcode genericSymbol specializers methodFunObj ...) ``` 其中: | opcode | role | | -----: | ------- | | 1 | primary | | 2 | before | | 3 | after | | 4 | around | 因此,恢复 defmethod 不能只扫描有名字的 procedure。很多 method body 并没有一个可供猜测的普通全局函数名。 --- ## 7. CXT 的加载原理 ### 7.1 为什么保存时的指针不能直接使用 CXT 中的对象引用最初来自制作 CXT 的进程。例如,某个 Symbol 的 value 可能保存为旧地址: ```text 0x00007f12xxxxxxxx ``` 另一个进程加载时,由于 ASLR、分配器状态和基础 Context 不同,新对象不可能仍位于同一地址。因此必须重定位。 ### 7.2 加载流程图 ```mermaid flowchart TD A["打开 CXT"] --> B["读取并校验 Header、版本、位数和字节序"] B --> C["根据 BlockHeader 分配对象页面"] C --> D["读取 object cells"] D --> E["读取 string blob、qword vector 和用户类型数据"] E --> F["建立旧页地址到新页地址的映射"] F --> G["调整 Symbol、List、FunObj、Array 等内部指针"] G --> H["按 pname / namespace 合并 Symbol"] H --> I["恢复变量值、函数 binding、class 和 generic"] I --> J["运行 User-type load callbacks"] J --> K["安装 OOP method 记录"] K --> L["按顺序执行 TopForm"] L --> M["执行 Context init / auto-init"] M --> N["CXT 对象出现在当前 SKILL 运行时"] ``` ### 7.3 Symbol 合并的顺序 加载 type-5 Symbol 时,核心步骤是: 1. 用 `newStringBase - savedStringBase` 调整 pname; 2. 重定位 namespace; 3. 用 `ilAddCntxtSym` 或 `ilAddCntxtSymNs` 查找/创建真实运行时 Symbol; 4. 复制必要 flags; 5. 重定位 plist; 6. 把保存时的 `ilcUnbound` 映射为当前进程的 `ilcUnbound`; 7. 重定位并安装普通变量 value; 8. 重定位并安装 function binding; 9. 重建 Symbol hash 链。 这解释了为什么同名函数或变量可以覆盖当前进程中的旧 binding,而不是生成一个对用户不可见的重复 Symbol。 --- ## 8. 源码恢复的基本原理 恢复 CXT 并不是“把每个字节翻译回源代码”,而是: ```text 加载对象图 -> 完成指针重定位 -> 找到 Symbol / class / generic / method -> 找到 FunObj 和 VCODE -> 调用 VCODE 反编译器得到 AST/form -> 把 AST/form 打印成 SKILL 文本 ``` ### 8.1 `pp` 为什么能打印 VCODE `pp` 的关键调用链可以概括为: ```mermaid flowchart LR A["pp(function)"] --> B["ilPp / ilPp1"] B --> C{"iliFunIsReadProtected?"} C -->|"是"| D["拒绝打印或报告 read-protected"] C -->|"否"| E["iliVcodeDecompFun / iliVcodeDecomp"] E --> F["生成 SKILL AST/form"] F --> G["pretty print 到 poport"] ``` 保护位不是 VCODE 加密。VCODE 指令和 literal 仍然存在;保护判断只是阻止普通 `pp` 继续进入反编译路径。 当前版本的 read-protect 判断并不只看一个位置。`iliFunIsReadProtected` 会检查 Symbol 侧的保护位,并根据 FunObj subtype 在两个函数侧位置中二选一: ```text Symbol + 0x02 的 bit 0x02 如果 (FunObj[1] & 7) != 0: 检查 FunObj + 0x02 的 bit 0x80 如果 (FunObj[1] & 7) == 0: 不检查 FunObj 自身的这个 bit; 改为跟随 *(FunObj + 0x18),检查被指向 guard 对象 +0x02 的 bit 0x80 ``` 也就是说,稳妥的 GDB 方案应核对 Symbol 侧 `0x02`,再按 subtype 核对 FunObj 或间接 guard 对象侧的 `0x80`,不能无条件把两个函数侧位置都当成保护位。上面的偏移只适用于本文分析的精确版本,不能视为跨版本公开格式。 ### 8.2 为什么优先复用官方反编译器 完全手写 VCODE 反编译器需要解决: - opcode 表; - 每个 opcode 的字段布局; - 控制流和跳转; - 参数和默认值; - literal pool; - 对象引用重定位; - 不同版本兼容; - SKILL/SKILL++ AST 重建。 而 `*.so` 本身已经包含官方加载器和 VCODE decompiler。因此三种实用恢复方案的共同思想都是: > 尽量复用 Cadence 自己的运行时对象模型和反编译器,而不是从零猜完整 VCODE 指令集。 --- ## 9. 三种恢复方案总览 ```mermaid flowchart TD A["需要恢复的 CXT"] --> B{"选择恢复路线"} B --> C["方案一:修改磁盘上的 Virtuoso .so"] B --> D["方案二:GDB 修改运行时内存,再调用 pp"] B --> E["方案三:C 程序直接调用 *.so"] C --> F["Virtuoso 内 pp 忽略保护判断"] D --> G["只清当前进程中的 Symbol / FunObj 保护位"] G --> H["Virtuoso 内 pp 输出源码"] E --> I["独立 Linux 进程加载 CXT"] I --> J["直接调用 VCODE decompiler 并打印 AST"] ``` | 对比项 | 方案一:改`.so` | 方案二:GDB 内存修改 +`pp` | 方案三:C 直接调用`.so` | | ------------------------ | ---------------------------------------- | ------------------------------------- | ------------------------------------------- | | 是否需要启动 Virtuoso | 是 | 是 | 否 | | 是否修改磁盘库文件 | 是 | 否 | 否 | | 是否依赖`pp` | 是 | 是 | 否 | | 保护位处理 | 永久绕过判断 | 只改当前进程内存 | 直接调用底层反编译器,可不走`pp` 门禁 | | 修改生命周期 | 直到换回原始`.so` | 进程退出、对象重载后失效 | 每次在独立进程中重新加载和恢复 | | 自动化难度 | 中 | 中 | 高,但完成后批处理最好 | | `defclass` / `defmethod` | 只改`pp` 不够,仍需元数据和 method table | 可以做,但需要额外 OOP 枚举与临时绑定 | 工具可以集成 class metadata 和 OOP 记录处理 | | native /`binary` | 无 VCODE,不能恢复 | 无 VCODE,不能恢复 | 无 VCODE,不能恢复 | | 对版本变化敏感度 | 很高 | 中到高 | 高,依赖私有 ABI | | 适合场景 | 临时研究旧版本 | 已有 Virtuoso 环境、单次或少量恢复 | 批量、无人值守、脱离 Virtuoso | | 主要风险 | 破坏安装、影响所有进程 | 错地址可能导致当前进程崩溃 | 兼容库和 ABI 不匹配可能崩溃 | --- ## 10. 方案一:修改 Virtuoso 的 `.so`,让 `pp` 忽略保护 这是传统二进制 patch 方案。基本思路是: 1. 在对应版本 `*.so` 中找到 `ilPp1` 或 `iliFunIsReadProtected`; 2. 找到“受保护则拒绝打印”的条件分支; 3. 修改分支,使其总是进入 VCODE 反编译路径,或者让保护判断恒定返回 false; 4. 让 Virtuoso 加载修改后的库; 5. 正常使用 `pp` 输出函数。 本方案只做简要介绍,因为它的问题比较明显: - 修改的是磁盘文件,会影响使用该库的所有 Virtuoso 进程; - 每个版本的机器码地址和分支都可能不同; - patch 错误会导致 Virtuoso 无法启动或随机崩溃; - 软件升级、校验或重新安装后 patch 会失效; - 必须保留原始库和校验值,并只对有权分析的文件使用。 若确实采用该方案,至少应在副本上操作,不要直接覆盖唯一的官方库。 --- ## 11. 方案二:GDB 修改运行时内存,然后使用 `pp` > 用户有时会写成 “GDP”,本文使用正确工具名 **GDB(GNU Debugger)**。 这是在现有 Virtuoso 环境中最实用、又不修改磁盘 `.so` 的方案。 ### 11.1 核心思想 ```text 官方 *.so 不变 -> Virtuoso 正常加载 CXT -> GDB 附加到 Virtuoso 进程 -> 找到目标 Symbol 和 FunObj -> 清除 Symbol + 0x02 的 0x02 -> 清除 FunObj/guard + 0x02 的 0x80 -> 继续/脱离进程 -> 使用官方 pp 反编译并打印 ``` 内存修改只影响当前 Virtuoso 进程。进程退出以后,修改自然消失。 ### 11.2 详细流程图 ```mermaid flowchart TD A["启动与 CXT 版本匹配的 Virtuoso"] --> B["把 CXT 放到 64bit 子目录并 loadContext"] B --> C["确认 getd(name) 是 funobj/VCODE,而不是 native binary"] C --> D["GDB attach 到 Virtuoso PID"] D --> E["ilMakeSym(name) 得到 type-5 Symbol"] E --> F["读取 Symbol + 0x20 的 function binding"] F --> G{"binding 的 type byte"} G -->|"1:直接 FunObj"| H["使用该 FunObj"] G -->|"5:Symbol wrapper"| I["再读取 wrapper + 0x20"] I --> H G -->|"其他或 nil"| J["停止:未加载或不是可恢复 VCODE"] H --> K["验证 FunObj byte 0 == 1"] K --> L["读取 flags 和 subtype,但暂不写内存"] L --> R{"FunObj subtype 是否为 0"} R -->|"是"| S["解析、验证并读取 fun+0x18 guard"] R -->|"否"| M["全部目标验证完成"] S --> M M --> Q["统一清除 Symbol 0x02 与 FunObj/guard 0x80"] Q --> N["continue 或 detach"] N --> O["在 Virtuoso 中调用 pp"] O --> P["把 poport 重定向到 recovered.il"] ``` ### 11.3 前提条件 开始前应满足: - Virtuoso、CXT 和 `*.so` 位数及版本匹配; - GDB 与 Virtuoso 在同一 Linux 主机; - 当前用户有权限 attach,或具有 `sudo`; - 目标 CXT 已经成功加载; - 目标定义是 SKILL VCODE,而不是 native/binary 函数; - 对重要 Virtuoso 会话先保存工作,避免调试错误造成数据丢失。 ### 11.4 正确放置并加载 CXT 64 位 `loadContext` 通常会在给定 base path 的 `64bit` 子目录寻找实际文件。 示例: ```sh mkdir -p /tmp/cxt_recover/64bit cp input.cxt /tmp/cxt_recover/64bit/demo ``` 在 Virtuoso SKILL 环境中: ```skill setSkillPath(cons("/tmp/cxt_recover" getSkillPath())) loadContext("/tmp/cxt_recover/demo") ``` 这里传给 `loadContext` 的是 base path `/tmp/cxt_recover/demo`,真正的文件位于 `/tmp/cxt_recover/64bit/demo`。 先检查函数是否存在: ```skill getd('someFunction) type(getd('someFunction)) ``` 一般判断: - `funobj`:可以继续检查 VCODE; - `binary`:这是 native/binary 函数,没有可供 VCODE decompiler 恢复的 SKILL body; - `nil`:函数没有加载成功、名字不对,或者它不是普通全局函数。 ### 11.5 找到 Virtuoso PID 并 attach 在 Linux 上查找进程: ```sh pgrep -af virtuoso ``` 不要只因为某个 PID 最新就直接 attach。可以继续确认: ```sh ps -fp <VIRTUOSO_PID> readlink -f /proc/<VIRTUOSO_PID>/exe ``` 确认它是专门用于恢复的会话后: ```sh sudo gdb -q -nx -p <VIRTUOSO_PID> ``` 进入 GDB 后先关闭分页: ```gdb set pagination off set print pretty on info sharedlibrary libil_sh info address ilMakeSym ``` 注意:GDB attach 后,Virtuoso 会暂停。这是正常现象。在执行 `continue` 或 `detach` 之前,Virtuoso 界面和 SKILL bridge 都不会响应。 如果需要处理很多函数,应在同一个 SSH/终端会话中完成一次 attach、批量修改和 detach,不要为每个函数重新 attach。 #### 调用 `ilMakeSym` 前先确认线程和停止点 后面的 `ilMakeSym(...)` 属于 GDB inferior call,也就是让被调试进程在暂停期间执行一段运行时函数。Virtuoso 是多线程程序;如果当前选中的线程不对,或者其他线程正持有 SKILL 运行时、分配器、GC、Context loader 的锁,inferior call 可能死锁或导致进程崩溃。 应先让专用 Virtuoso 会话处于 CIW/SKILL 空闲状态,再检查线程: ```gdb info threads thread apply all bt 6 ``` 根据线程名称和 backtrace 找到 SKILL 主执行线程,再显式选择并复核: ```gdb thread <N> bt ``` 不要机械地假定 thread 1 一定正确。若不能确认主执行线程,或 backtrace 显示正在执行 `malloc`、GC、`loadContext`、用户回调或持锁路径,就不要从 GDB 调用 `ilMakeSym`;先 `detach`,让进程回到已知空闲点后重新安排一次受控 attach。 ### 11.6 在 GDB 中找到 Symbol 调用运行时自己的 `ilMakeSym`: ```gdb set $sym = (unsigned char *)ilMakeSym("someFunction") p/x $sym ``` 确认名称: ```gdb x/s *(char **)($sym + 0x08) ``` 预期应打印 `someFunction`。 如果 `$sym` 为 0,或者名称不正确,不要继续修改内存。 `ilMakeSym` 对不存在的名称可能创建一个新 Symbol,因此候选名必须先在 SKILL 中通过 `getd` 验证。仅仅看到 `$sym` 非 0,并不能证明 CXT 中原本存在该函数。 ### 11.7 找到 function binding 和真正的 FunObj 读取 Symbol `+0x20`: ```gdb set $def = *(unsigned char **)($sym + 0x20) p/x $def ``` 如果 `$def == 0`,说明没有普通函数 binding。应回到 Virtuoso 检查 CXT 是否成功加载以及函数名是否正确。 读取对象 type byte: ```gdb p/x *(unsigned char *)$def ``` 常见情况: - `0x01`:`$def` 已经是 FunObj; - `0x05`:`$def` 是一层 Symbol wrapper,需要再取它的 `+0x20`; - 其他值:不要按本文偏移强行修改。 设置 `$fun`: ```gdb set $fun = $def ``` 如果上一步 type 是 `0x05`,再执行: ```gdb set $fun = *(unsigned char **)($def + 0x20) ``` 最后再次验证: ```gdb p/x $fun p/x *(unsigned char *)$fun ``` 只有 type byte 为 `0x01` 时,才把它当作本文的 version 602 FunObj。 ### 11.8 检查并清除 read-protected 位 修改内存前,应先完成所有指针和类型检查。不要先改 Symbol/FunObj,再去解析 subtype-0 guard,否则 guard 异常时会留下“只改了一半”的对象状态。 先查看 Symbol 和 FunObj 前几个字节: ```gdb x/8bx $sym x/8bx $fun ``` 分别保存 flags byte: ```gdb set $old_sym_flags = *(unsigned char *)($sym + 0x02) set $old_fun_flags = *(unsigned char *)($fun + 0x02) p/x $old_sym_flags p/x $old_fun_flags ``` 当前版本中,下面任一条件都可能让 `pp` 判定为 read-protected: ```text $old_sym_flags & 0x02 $old_fun_flags & 0x80;普通 subtype 路径 ``` 接着检查 FunObj subtype: ```gdb set $subtype = *(unsigned char *)($fun + 0x01) & 7 p/d $subtype ``` 如果 `$subtype == 0`,当前版本的保护函数不会直接使用 `$fun` 的 flags,而会跟随 FunObj `+0x18`。先解析 guard,但仍然不要写内存: ```gdb set $guard = *(unsigned char **)($fun + 0x18) p/x $guard ``` 确认 `$guard` 非 0、低两位为 0,并用 `x` 验证地址确实可读: ```gdb x/8bx $guard set $old_guard_flags = *(unsigned char *)($guard + 0x02) p/x $old_guard_flags ``` 如果 `$guard` 无效、未对齐或 `x/8bx` 报 `Cannot access memory`,立即停止,不修改任何 flags。 只有 Symbol、FunObj,以及 subtype 0 时的 guard 全部验证完成以后,才统一写入: ```gdb set {unsigned char}($sym + 0x02) = $old_sym_flags & 0xfd if $subtype == 0 set {unsigned char}($guard + 0x02) = $old_guard_flags & 0x7f else set {unsigned char}($fun + 0x02) = $old_fun_flags & 0x7f end p/x *(unsigned char *)($sym + 0x02) p/x *(unsigned char *)($fun + 0x02) ``` 如果 subtype 为 0,还应另外查看: ```gdb p/x *(unsigned char *)($guard + 0x02) ``` `0xFD` 只清 bit `0x02`,`0x7F` 只清 bit `0x80`。不要把整个 byte 直接写成 0,因为同一个 byte 里可能还有其他有效 flags。 ### 11.9 让 Virtuoso 恢复运行 最简单方式是直接 detach: ```gdb detach quit ``` 这样 Virtuoso 立即继续运行,内存中的保护位保持为已清除,直到该对象被替换或进程退出。 如果必须在打印后恢复原 flags,可以让 GDB 保持 attach: 1. 执行 `continue`; 2. 通过 Virtuoso UI 或另一个已有的控制通道执行 `pp`; 3. 回到 GDB 按 `Ctrl-C` 暂停; 4. **先重新验证对象身份,再写回。** 进程继续运行期间,CXT 可能被重载,对象也可能被替换。不能直接假定旧 `$sym/$fun/$guard` 仍然有效。至少应重新检查: ```gdb p/x *(unsigned char *)$sym x/s *(char **)($sym + 0x08) set $current_def = *(unsigned char **)($sym + 0x20) p/x $current_def ``` 要求: - `$sym` 仍是 type 5,pname 仍是同一函数; - direct FunObj 情况下 `$current_def == $fun`; - wrapper 情况下,当前 wrapper 及其 `+0x20` 最终仍解析到原 `$fun`; - subtype 0 时,当前 `*(fun+0x18)` 仍等于原 `$guard`; - 所有地址仍可读。 只有全部相同,才按实际修改过的目标恢复: ```gdb set {unsigned char}($sym + 0x02) = (*(unsigned char *)($sym + 0x02) & 0xfd) | ($old_sym_flags & 0x02) if $subtype == 0 set {unsigned char}($guard + 0x02) = (*(unsigned char *)($guard + 0x02) & 0x7f) | ($old_guard_flags & 0x80) else set {unsigned char}($fun + 0x02) = (*(unsigned char *)($fun + 0x02) & 0x7f) | ($old_fun_flags & 0x80) end detach quit ``` 这里不能直接把整个 `$old_*_flags` byte 写回。`continue` 期间,同一 byte 中与恢复无关的其他 bit 可能已经被 Virtuoso 合法更新;上面的表达式只恢复本次清除的 `0x02` 或 `0x80`,同时保留当前 byte 的其他 bit。 若任一身份或指针发生变化,不要向旧地址写回。最稳妥的处理是结束这个专用恢复进程;进程退出后所有内存修改自然消失。一般情况下,直接 detach、完成导出后关闭专用 Virtuoso,比运行中恢复旧 flags 更简单可靠。 ### 11.10 使用 `pp` 输出到文件 在 Virtuoso SKILL 环境中: ```skill let((oldPo p) oldPo = poport p = outfile("/tmp/recovered.il") unless(outportp(p) && openportp(p) error("Cannot open /tmp/recovered.il for writing\n") ) unwindProtect( progn( poport = p pp(someFunction) ) progn( poport = oldPo when(outportp(p) && openportp(p) close(p) ) ) ) ) ``` 正常情况下,`/tmp/recovered.il` 中会出现反编译后的 SKILL 定义。 这里先检查 `outfile`,并使用 `unwindProtect` 保证即使 `pp` 报错或被中断,也会恢复 `poport` 并关闭输出端口。批量恢复时可以在受保护的 `progn` 内对每个 `pp` 使用 `errset`,避免一个函数失败后跳过整个列表的其余项目。 ### 11.11 多函数恢复 对多个函数,核心循环始终相同: ```text 函数名 -> ilMakeSym -> 验证 Symbol type 5 -> Symbol + 0x20 -> 解析可能的 wrapper -> 验证 FunObj type 1 -> 清除 Symbol + 0x02 的 0x02 -> 清除 FunObj/guard + 0x02 的 0x80 -> pp ``` 可以把 GDB 命令写成 command file 或自定义 GDB command,一次 attach 后处理一批函数,再统一 detach。不要为每个函数反复重启 Virtuoso或重新建立远程连接。 下面是一个仅适用于本文目标版本的、带基本类型检查的 GDB command 示例。候选函数名应先在 SKILL 中确认存在。这个宏只自动处理 `Symbol +0x20` 直接指向 type-1 FunObj 的常见情况;遇到 type-5 wrapper 会安全跳过,需要按 11.7 节先人工解析 wrapper: ```gdb define cxt_unprotect set $sym=(unsigned char*)ilMakeSym($arg0) set $can_patch=1 if $sym == 0 printf "SKIP: ilMakeSym returned NULL\n" set $can_patch=0 else if (((long)$sym & 3) != 0) || (*(unsigned char*)$sym != 5) printf "SKIP: not a type-5 Symbol, address=%p\n", $sym set $can_patch=0 else set $fun=*(unsigned char**)($sym+0x20) if $fun == 0 printf "SKIP: function binding is nil, symbol=%p\n", $sym set $can_patch=0 else if (((long)$fun & 3) != 0) || (*(unsigned char*)$fun != 1) printf "SKIP: binding is not a type-1 FunObj, binding=%p\n", $fun set $can_patch=0 else set $sym_before=(unsigned int)*(unsigned char*)($sym+2) set $fun_before=(unsigned int)*(unsigned char*)($fun+2) set $subtype=(unsigned int)(*(unsigned char*)($fun+1) & 7) if $subtype == 0 set $guard=*(unsigned char**)($fun+0x18) if ($guard != 0) && (((long)$guard & 3) == 0) # This read aborts the command before any write if memory is inaccessible. set $guard_before=(unsigned int)*(unsigned char*)($guard+2) else printf " WARN: subtype-0 guard is invalid: %p\n", $guard set $can_patch=0 end end if $can_patch set {unsigned char}($sym+2)=(*(unsigned char*)($sym+2) & 0xfd) if $subtype == 0 set {unsigned char}($guard+2)=(*(unsigned char*)($guard+2) & 0x7f) printf "OK: sym=%p flags=%02x->%02x guard=%p flags=%02x->%02x subtype=0\n", $sym, $sym_before, (unsigned int)*(unsigned char*)($sym+2), $guard, $guard_before, (unsigned int)*(unsigned char*)($guard+2) else set {unsigned char}($fun+2)=(*(unsigned char*)($fun+2) & 0x7f) printf "OK: sym=%p flags=%02x->%02x fun=%p flags=%02x->%02x subtype=%u\n", $sym, $sym_before, (unsigned int)*(unsigned char*)($sym+2), $fun, $fun_before, (unsigned int)*(unsigned char*)($fun+2), $subtype end end end end end end end ``` 使用: ```gdb cxt_unprotect "functionA" cxt_unprotect "functionB" cxt_unprotect "functionC" detach quit ``` 把宏和函数列表放进一个纯文本 `.gdb` 文件后,可以用 `-batch -x` 一次 attach 批量执行。这样即使有很多函数,也不需要为每个函数单独 attach。 批处理前必须先在同一专用 Virtuoso 进程上手工验证安全线程和一个函数。`-batch` 不会自动判断哪个线程适合执行 `ilMakeSym`;若线程编号可能变化,应从一开始在 GDB 控制下启动专用进程或使用已验证的安全停止点,不要把该宏直接用于生产会话。 函数名可以通过加载前后 oblist/function binding 差分获得,不需要依赖固定前缀。但要注意: - 新增 Symbol 容易发现; - 已有 Symbol 的 function binding 被覆盖时,也要比较 `ilGetd` 前后值; - defmethod body 可能没有普通全局函数名,必须结合 OOP 安装记录; - 只扫描 CXT 中的可打印字符串可能漏项或产生误报。 ### 11.12 GDB 方案恢复 class、generic 和 method 的边界 清保护位只解决“允许 `pp` 读取 FunObj”这一件事,不负责自动发现和重建所有 OOP 定义: - 普通 `procedure`:清对应 Symbol/FunObj 后,可以直接 `pp(functionName)`; - `defgeneric`:`pp(genericName)` 通常只能得到 generic 声明; - `defmethod`:需要先从 generic method table 获得 specializers、role 和 method FunObj,把 method FunObj 临时绑定到一个 Symbol,清临时 Symbol/FunObj 的保护,再 `pp` 方法体,最后重建 `defmethod` 头; - `defclass`:主要依靠 class metadata、superclass 和 slot specs 重建,单纯清 FunObj 保护位并不足够; - 纯变量 CXT:如果没有 FunObj/VCODE,就不走 `pp` 路径,应读取 Symbol `+0x18` 的 value binding。 所以完整 GDB/Virtuoso 自动化通常是:先发现 class/generic/method/procedure,再一次 attach 批量解保护,最后分别用 metadata、method table 和 `pp` 生成输出。 ### 11.13 GDB 方案常见故障 #### `ptrace: Operation not permitted` 可能原因: - 当前用户不是 Virtuoso 进程所有者; - Linux `ptrace_scope` 限制; - 容器或安全策略禁止 attach。 优先使用进程所有者或已有 `sudo` 权限。不要在不清楚服务器安全策略时永久放宽系统配置;如临时调整,完成后恢复原值。 如果 `sudo gdb` 仍受 Yama 限制,而且确实获得了服务器管理授权,可以临时保存并恢复配置: ```sh old_scope=$(cat /proc/sys/kernel/yama/ptrace_scope) || exit 1 restore_ptrace_scope() { sudo sysctl -w "kernel.yama.ptrace_scope=$old_scope" >/dev/null } trap restore_ptrace_scope EXIT HUP INT TERM sudo sysctl -w kernel.yama.ptrace_scope=0 || exit 1 # 在同一个 shell 中执行本次 GDB 操作。 # 正常退出、SSH HUP 或 Ctrl-C 时,trap 都会尝试恢复原值。 sudo gdb -q -nx -p <VIRTUOSO_PID> # 完成后立即显式恢复。只有写回且复读确认成功,才取消 trap。 if restore_ptrace_scope && \ current_scope=$(cat /proc/sys/kernel/yama/ptrace_scope) && \ [ "$current_scope" = "$old_scope" ]; then trap - EXIT HUP INT TERM else echo "未能确认 ptrace_scope 已恢复;保留退出 trap 并停止。" >&2 exit 1 fi ``` 通常不需要修改该设置;优先尝试同一用户和 `sudo gdb`。`trap` 不能处理 `SIGKILL` 或主机掉电,所以即使采用该方法,也应在结束后再次读取 `/proc/sys/kernel/yama/ptrace_scope` 确认已经恢复。 #### `ilMakeSym` 无法调用 检查: - GDB 是否 attach 到真正加载 `*.so` 的进程; - 动态符号是否可见; - CXT 是否已经加载; - 当前线程是否处于可以安全调用运行时函数的位置。 如果在不稳定停止点调用函数导致问题,可以先让进程停在 SKILL 空闲状态再 attach。 #### `$def` 为 0 通常表示: - 函数名错误; - CXT 没有加载成功; - 该 Symbol 只有普通变量值,没有函数定义; - 它是 method body 或其他非普通全局定义。 #### FunObj type 不是 1 不要直接写 `$def + 2`。先判断是否存在 type-5 wrapper;若仍不是 type 1,应重新分析该版本的对象布局。 #### 清除以后 `pp` 仍然拒绝 可能原因: - 修改的不是最终 FunObj; - CXT 重新加载后替换了对象; - 没有清除 Symbol `+0x02` 的 bit `0x02`; - subtype 为 0,但没有处理 `fun+0x18` 指向对象的 bit `0x80`; - `pp` 面对的是 native/binary,而不是 VCODE; - 函数是 closure,需要先取得内部 FunObj。 #### 修改后 Virtuoso 崩溃 最常见原因是: - 使用了错误版本的偏移; - `$def` 为 nil 或不是有效对象; - 把整个 flags byte 清零; - 在运行时正在修改对象时强行写内存。 重新启动进程即可恢复磁盘上未修改的官方库。重要工作应在 attach 前保存。 --- ## 12. 方案三:C 程序直接调用 `*.so` 这是本次实现的脱离 Virtuoso 方案。它在独立 Linux x86-64 进程中加载匹配版本的 `*.so`,直接复用官方 Context loader 和 VCODE decompiler,因此不启动 Virtuoso、不调用 `pp`,也不经过 `ilPp1` 的保护门禁。 ```mermaid flowchart TD A["C 程序启动"] --> B["dlopen *.so,RTLD_NOW | RTLD_GLOBAL"] B --> C["ilInitLisp 初始化最小 SKILL 运行时"] C --> D["记录加载前 oblist/function/class binding"] D --> E["ilLoadContext 加载、重定位并安装 CXT"] E --> F["记录 OOP method 安装信息和加载后 binding"] F --> G["前后差分,无前缀发现定义"] G --> H["iliVcodeDecomp 生成 AST"] H --> I["打印并重建 class/generic/method/procedure"] ``` 因为 `*.so` 原本依赖完整 Cadence 宿主,独立程序还需要一个最小 ABI 兼容层补齐内存、page map、hash、路径和诊断类外部符号。自动模式通过加载前后 oblist/function/class binding 差分发现名称,并通过 `iliInstallOOPExecute` 记录取得 method 的 generic、specializers、FunObj 和 role,所以不要求函数名前缀。 经验证,该方法可以支持 `procedure/nprocedure/mprocedure`、`defclass`、`defgeneric` 和 `defmethod`等恢复。 纯变量 CXT 会被报告为没有可恢复定义。native/`binary` 函数同样没有 VCODE 可供恢复。由于 `ilLoadContext` 可能执行 TopForm、用户类型回调和初始化逻辑,不可信 CXT 应放在隔离的专用进程或虚拟机中处理。 --- ## 13. 三种方案的本质关系 三种方案表面不同,底层都围绕同一条能力链: ```text CXT object graph -> FunObj -> VCODE -> Cadence 官方 VCODE decompiler -> AST/form -> SKILL 文本 ``` 差别只在于怎样到达 decompiler: - 修改 `.so`:永久改变 `pp` 的保护分支; - GDB:临时改变当前 Symbol/FunObj 的保护状态,让原版 `pp` 继续; - C 直调:绕过 `pp` 门禁,直接调用底层加载和反编译函数。 因此,“保护位”本身不是恢复算法。真正完成代码重建的是: - CXT loader; - pointer relocation; - Symbol/class/method discovery; - VCODE decompiler; - AST printer。 --- ## 14. 能恢复什么,不能恢复什么 ### 14.1 通常可以恢复 - VCODE procedure 的主要控制流和表达式; - 参数列表、required/optional 参数; - 很多 literal 和字符串; - class 名、父类和 slot specs; - generic 参数结构; - method specializers 和 role; - 普通变量的最终值,前提是实现对应对象序列化; - TopForm 中仍保留的逻辑 form,前提是在执行释放前捕获。 ### 14.2 通常无法原样恢复 - 原始注释; - 原始缩进和换行; - 宏展开前的精确文本; - 编译优化前的冗余表达式; - 普通 snapshot 变量的原始 RHS; - 已经丢失的局部调试符号; - native/binary 函数的 SKILL 源码; - CXT 未包含、只依赖外部环境的定义。 ### 14.3 版本和依赖风险 恢复失败不一定表示 CXT 损坏。常见原因还包括: - CXT 与 `*.so` 版本不匹配; - 32/64 位不匹配; - 缺少基础 Context; - 依赖的系统库缺失; - 用户类型 load callback 依赖完整 Virtuoso 服务; - 保存时引用了当前最小运行时中不存在的旧页; - 私有 ABI 在另一个版本中发生变化。 --- ## 15. 面向初学者的常见问题 ### 15.1 CXT 里面有函数名字符串吗? 函数名来自 type-5 Symbol 的 pname。pname 字节位于 tail string blob,Symbol `+0x08` 指向它。 但是,直接扫描字符串不能可靠确定哪些字符串一定是函数名。更可靠的方法是加载 CXT 后遍历 oblist,并检查 Symbol 的 function binding。 ### 15.2 为什么不需要函数名前缀? 因为可以比较加载前后的完整 Symbol/function binding,而不是只搜索满足某个前缀的字符串。 ### 15.3 read-protected 是不是把 VCODE 加密了? 不是。当前分析中它主要是一个打印/反编译门禁标志。VCODE 和 literal 仍在对象图中。 ### 15.4 清除保护位为什么 `pp` 就能工作? 因为 `pp` 原本已经拥有 VCODE decompiler,只是在进入它之前调用了保护判断。清除对应位后,控制流继续进入官方反编译函数。 ### 15.5 `defclass` 是否也有一段 VCODE 源码? 不能这样理解。class 主要保存为 class metadata、slot specs、superclass 和 Symbol property。恢复时需要根据 metadata 重建 `defclass` form。 ### 15.6 `defmethod` 为什么比 procedure 难? method body 虽然也是 FunObj/VCODE,但 method 属于哪个 generic、specializer 是什么、是 before/after/around 还是 primary,都不一定包含在一个普通函数名里。这些信息要从 OOP 安装记录和 method table 获得。 ### 15.7 CXT 只有变量时有没有 VCODE? 如果变量只保存整数、字符串、List、Array 等普通数据,普通 value binding 本身不需要 FunObj/VCODE。 如果变量本身绑定到 closure/FunObj,就会出现相应 VCODE。TopForm 保存的是可重放的 form/AST 和执行上下文,它本身并不必然是 type-1 FunObj;是否还包含 VCODE,取决于 TopForm 引用或定义的具体内容。 ### 15.8 为什么输出代码与原代码括号或空格不同? 因为恢复器打印的是反编译后的 AST/form,不是复制原始文本。只要 token 和语义一致,排版差异通常不是恢复错误。 ### 15.9 Windows能直接运行 Linux 的 `*.so` 吗? 不能原生 `dlopen`。本文分析库是 Linux x86-64 ELF。独立 C 方案应在 Linux x86-64 服务器、虚拟机或兼容容器中运行。 --- ## 16. 当前版本关键函数索引 本表是基于Virtuoso 618版本: | 函数 | 地址 | 作用 | | ----------------------- | --------: | ---------------------------------- | | `iliSaveContext` | `0x6E360` | 保存 Context 主流程 | | `ilSaveContext` | `0x70F50` | 导出的保存入口 | | `iliLoadContext` | `0x70FE0` | 加载 Context 主流程 | | `ilLoadContext` | `0x73E40` | 导出的加载入口 | | `ilXDRBlock` | `0x6D5F0` | 按 blockType 读取/转换 cells | | `ilXDRCntxtHdr` | `0x6DC30` | Context header 转换 | | `ilAdjustPointers1` | `0x67930` | 旧地址到新地址的核心重定位 | | `ilMakeSym` | `0xEDEB0` | intern/查找 Symbol | | `ilGetd` | `0xEBAC0` | 读取 function binding | | `ilGetSym` | `0xF29F0` | 读取普通变量 value binding | | `ilPp1` | `0xDC710` | `pp` 主要实现路径 | | `iliFunIsReadProtected` | `0xF9430` | 检查函数 read-protected | | `iliVcodeDecomp` | `0xB2000` | VCODE 到 form/AST | | `iliVcodeDecompFun` | `0xB4BD0` | FunObj 反编译路径 | | `iliInstallOOPExecute` | `0x6AF80` | 安装 class/generic/method OOP 记录 | | `iliTopFormLoadContext` | `0xF24A0` | 载入 TopForm | | `iliExecuteTopForms` | `0xF5040` | 按顺序执行 TopForm | --- ## 17. 实际选择建议 | 目标 | 建议方案 | | ------------------------------------- | ---------------------------------------------------------- | | 已有正常 Virtuoso,只恢复少量已知函数 | GDB 内存修改 +`pp` | | 不允许修改安装目录 | GDB 或 C 直调,不采用磁盘 patch | | 需要自动发现所有函数,不想提供前缀 | C 直调 + oblist 前后差分 | | 需要批量处理大量 CXT | C 直调 | | 需要快速验证某一个保护位判断 | GDB | | 需要 defclass/defmethod 的完整结构 | 优先使用可以实现 class/OOP 记录处理的 C 直调方案 | | 需要恢复普通变量最终值 | 在 C 直调方案中增加`ilGetSym` snapshot 和对象序列化 | | 需要恢复 TopForm 原逻辑 | 在`iliTopFormLoadContext` 到 `iliExecuteTopForms` 之间截获 | 总体上: - 修改磁盘 `.so` 的传统方案风险最高,只适合受控研究环境; - GDB 方案最适合已有 Virtuoso 的单次恢复和验证; - C 直调方案工程量最大,但完成后最适合自动化、无前缀发现和批量恢复。 --- ## 18. 总结 CXT 的核心不是“加密后的源码文本”,而是: ```text 可变长 Header + 多个对象 Block + String / Qword / User-type Tail + 保存地址到加载地址的重定位信息 + Symbol、FunObj、VCODE、class 和 method 对象图 ``` 恢复代码的关键也不是单纯清除一个保护位,而是完整利用: ```text 官方 Context loader + Symbol 和 OOP 定义发现 + 官方 VCODE decompiler + AST/form printer ``` 保护位只决定普通 `pp` 是否愿意进入反编译路径。传统 `.so` patch 和 GDB 内存修改都是让 `pp` 继续执行;独立 C 方案则直接调用保护门禁之后的底层能力。 理解这一点以后,CXT 的文件布局、变量存储、VCODE 位置以及三种恢复方式就能够统一到同一套运行时对象模型中。 最后修改:2026 年 07 月 12 日 © 允许规范转载 赞 1 如果觉得我的文章对你有用,请随意赞赏