s-macke/starflight-reverse

GitHub: s-macke/starflight-reverse

对1986年Forth语言编写的经典游戏Starflight进行逆向工程,破解加密符号并将字节码转译为可读C风格代码的研究项目。

Stars: 217 | Forks: 15

# Starflight-Reverse ## Starflight 是什么? ![Starflight 1 PC版](https://static.pigsec.cn/wp-content/uploads/repos/2026/03/945dceec4b105607.gif) 早在 80 年代,一家名为 Binary Systems 的默默无闻的公司发布了游戏 Starflight。该游戏让玩家扮演一名被派去探索银河系的星际飞船船长。游戏没有设定固定的路径,允许玩家在采矿、舰对舰战斗和与外星人外交之间自由切换。随着玩家发现一个古老的种族正在导致恒星燃烧并摧毁所有生物,游戏的宏大情节逐渐展开。 这款游戏受到了当代和现代评论家的广泛赞誉,是最早的沙盒游戏实例之一。这款游戏在其发布后的几十年里影响了众多其他游戏的设计。 要了解更多关于该游戏的信息,请查看以下链接: * [Wikipedia](https://de.wikipedia.org/wiki/Starflight) * [The Digital Antiquarian](https://www.filfre.net/2014/10/starflight/) * [Starflight 资源页面](http://starflt.com) * [从遗忘中挽救的技术文章](https://github.com/s-macke/starflight-reverse/tree/master/webarchive) * [Starflight 1 评测](http://crpgaddict.blogspot.de/search/label/Starflight) * [Starflight 2 评测](http://crpgaddict.blogspot.de/search/label/Starflight%20II) * 其他粉丝网站或项目 [1](http://bravearmy.com/starflight/) [2](http://otherelectricities.com/neckdeep/starflight.html) [3](https://www.dosgameclub.com/starflight/) [4](http://blakessanctum.x10.mx/Games/Starflight/) [5](https://www.starflight3.com/) 你可以在 [GoG](https://www.gog.com/game/starflight_1_2) 购买该游戏 ## 这个项目是关于什么的? 我第一次听说这个游戏时就想玩它。然而,那时我太年轻了,不会说英语。20 年后我又试了一次,那是一次非常愉快的经历。探索很有趣,故事情节史诗般宏大,结局有一个惊喜,那是我经历过的最好的惊喜之一。当然,游戏并没有很好地经受住时间的考验,但你可以感受到开发者对游戏的投入。这款游戏既有艺术的一面,也有工匠对细节的关注。 玩这款真正令人惊叹的游戏很有趣,对该游戏进行逆向工程也同样有趣。你跟随开发者的脚步,体验他们的思维过程,仿佛又回到了 1985 年。 对于这个游戏,要期待意想不到的事情。通常,当你对这样一个老游戏进行逆向工程时,你必须面对成千上万行纯汇编代码,你可以使用 IDA Pro 等常用工具进行分析。但这次不一样。实际上,对于这个游戏,你可以把常用的工具扔掉了。它们毫无用处。你得靠自己。原因是 Starflight 是用 [Forth](https://en.wikipedia.org/wiki/Forth_(programming_language)) 编写的,这是一种我几乎不了解的语言。 ## Forth Forth 是语法上极致极简的语言。除了“单词”之间的空格外,没有其他语法。你基本上可以用几行代码编写一个 Forth 阅读器和解释器。 在现代语言中,你会写类似这样的代码 ``` print(2+3) ``` 来打印 2+3 的结果。然而在 Forth 中,它看起来是这样的。 ``` 2 3 + . ``` Forth 是一台堆栈机器,采用 [逆波兰表示法](https://en.wikipedia.org/wiki/Reverse_Polish_notation)。解释如下 * 将 2 压入栈顶 * 将 3 压入栈顶 * 弹出最后两个栈顶条目并将它们相加。将结果压回栈顶。 * 从栈中弹出顶部的值并打印它 语法很简单,解释器也很简单。“2”、“3”、“+”和“.”只是被称为“单词”。数据和代码之间没有语法区别。当然,这种语言符合早期家用计算机的局限性。 ## 反汇编描述 当你剖析可执行文件 STARFLT.COM 时,它会揭示出一些奇妙的内部结构 * 编译后的代码保留了 Forth 源代码的结构。编译器没有进行优化。源代码中的一个单词对应编译后代码中的两个字节。 * x86-汇编代码占可执行文件大小的比例不到 5% * 可执行文件中超过 90% 实际上是 16 位指针。 * 大约 6000 个单词名称中有 2000 个(即现在所说的调试符号)仍留在代码中,但是加密的。这使我们能够对很大一部分原始源代码进行逆向工程。 * Forth 解释器(不是编译器)仍然是可执行文件的一部分,并且可以启用 * 可执行文件很慢。除了一些汇编优化例程外,代码仅在代码中跳转就浪费了至少 50% 的 CPU 周期。 * 可执行文件大量使用代码覆盖,这使得解码更加复杂 ## 主要构建块 正如上面解释的,Forth 是一台堆栈机器。作为编码机制,它使用 [间接线程](https://en.wikipedia.org/wiki/Threaded_code#Indirect_threading),这是一种非常节省空间的存储编译代码的方法。线程代码的形式本质上完全由对子程序的调用组成。间接线程使用指向位置的指针,而这些位置又指向机器码。 假设你的指令指针指向地址 0x1000 并包含 16 位值 Read16(0x1000)=0x0f72。 ``` 0x1000: dw 0x0f72 ``` 值 0x0f72 是 Forth 单词 '+' 的编码等价物。记住上面的描述。单词 '+' 弹出最后两个栈顶条目,将它们相加并将结果压回栈顶。根据间接线程,这个 16 位值 0x0f72 是一个指向位置的指针,该位置又指向机器码。当你读取内存内容 Read16(0x0f72) 时,你会得到指向 0x0f74 的指针。确实,当你查看这个内存位置并对其进行反汇编时,你会收到以下内容 ``` 0x0f72: dw 0x0f74 0x0f74: pop ax 0x0f75: pop bx 0x0f76: add ax, bx 0x0f78: push ax 0x0f79: lodsw 0x0f7a: mov bx, ax 0x0f7c: jmp word ptr [bx] ``` 前四条指令准确地执行了单词“+”应该执行的操作。从“lodsw”开始的后三条汇编指令增加指令指针并跳转到下一个代码。 让我们继续。现在指令指针指向 0x1002 ``` 0x1002: dw 0x53a3 ``` 读取地址 0x53a3 显示 ``` 0x53a3: dw 0x1d29 0x53a5: dw 0x0001 ``` 以及相应的代码 ``` 0x1d29: inc bx 0x1d2a: inc bx 0x1d2b: push bx 0x1d2c: lodsw 0x1d2d: mov bx,ax 0x1d2f: jmp word ptr [bx] ``` 此时,寄存器 bx 包含单词地址 0x53a3。所以这段代码只是将地址 0x53a5 压入栈顶。我们所做的是为程序提供一个指向变量的指针。该变量的内容为 0x0001。Forth 单词 '@' 会从栈中弹出地址,读取其内容并将其压回栈中。 到目前为止,我可以识别出 6256 个包含代码或数据的单词。 * 3711 个是单词,它们执行其他单词。我想你可以称它们为函数。 * 906 个 16 位变量或数据数组。在极少数情况下(~20),数据数组包含 x86 机器码 * 356 个数据结构,定义存储在磁盘上的表的内容(见下文) * 346 个数据结构,定义实例树数据结构的内容(见下文) * 278 个单词包含 x86 机器码 * 235 个 16 位常量 * 127 个 switch-case 表达式 * 105 个单词包含定义代码覆盖的数据结构 * 其他单词属于不同类型 这实际上就是你需要了解的关于代码结构的全部内容。 正如你所看到的,这可能是一种节省空间的编码方式,但在速度上却是一场灾难。每隔几条机器码指令,你就必须跳转到一个不同的代码块。 C 语言中间接线程的等价形式如下所示。 ``` uint16_t instruction_pointer = start_of_program_pointer; void Call(uint16_t word_adress) { // the first two byte of the word's address contain // the address of the corresponding code, which must be executed for this word uint16_t code_address = Read16(word_address); switch(code_address) { . . . case 0x0f74: // word '+' Push16(Pop16() + Pop16()); break; . . . } } void Run() { while(1) { uint16_t word_address = Read16(instruction_pointer); instruction_pointer += 2; Call(word_address); } } ``` 为特定单词执行的代码可以访问 5 个主要变量(16 位) * instruction pointer(寄存器 si):这指向 Forth 中更复杂的函数(“单词”)内部。它指向内存中接下来必须执行的 Forth “单词”的地址。指令指针可以通过单词的代码进行更改,以进行分支和循环控制。 * stack pointer(寄存器 sp):这是一台堆栈机器,因此需要一个栈指针。Push 会将一个项目放入栈中。Pop 从栈顶检索一个项目。 * call stack pointer(寄存器 bp):包含函数的返回地址。也用于临时存储项目。 * word address(寄存器 bx):前 2 个字节包含指向该单词的 x86 机器码的地址。之后,可以有可选数据,如常量、变量和数组。在上面的“+”示例中,它包含机器码本身。 * code address(寄存器 ip):必须执行的 x86-机器码 ## 翻译 反汇编器将 FORTH 代码转译为 C 风格的代码。大多数转译后的代码都可以编译。要理解程序的作用,请看下表。它以“字节码”(主要是 16 位指针)作为输入,并将其转换为 C。 Forth 代码: ``` : .C ( -- ) \ Display context stack contents. CR CDEPTH IF CXSP @ 3 + END-CX DO I 1.5@ .DRJ -3 +LOOP ELSE ." MT STK" THEN CR ; EXIT ``` 转换: | 16 位指针 | FORTH | C | |-----------------|-------------|-------------------------------------------------------------------------| | | : .C ( -- ) | `void DrawC() { ` | | | | ` unsigned short int i, imax; ` | | 0x0642 | CR | ` Exec("CR"); ` | | 0x75d5 | CDEPTH | ` CDEPTH(); ` | | 0x15fa 0x0020 | IF | ` if (Pop() != 0) { ` | | 0x54ae | CXSP | ` Push(Read16(pp_CXSP) + 3);` | | 0xbae | @ | | | 0x3b73 | 3 | | | 0x0f72 | + | | | 0x4ffd | END-CX | ` Push(Read16(cc_END_dash_CX));` | | 0x15b8 | DO | ` i = Pop();` | | | | ` imax = Pop();` | | | | ` do {` | | 0x50e0 | I | ` Push(i);` | | 0x4995 | 1.5@ | ` _1_dot_5_at_();` | | 0x81d5 | .DRJ | ` DrawDRJ();` | | 0x175d 0xfffd | -3 | ` Push(-3);` | | 0x155c 0xffff | +LOOP | ` int step = Pop();` | | | | ` i += step;` | | | | ` if (((step>=0) && (i>=imax)) \|\| ((step<0) && (i<=imax))) break;` | | | | ` } while(1);` | | 0x1660 0x000b | ELSE | ` } else {` | | 0x1bdc | " MT STK" | ` PRINT("MT STK", 6);` | | 0x06 | | | | 0x4d | 'M' | | | 0x54 | 'T' | | | 0x20 | ' ' | | | 0x53 | 'S' | | | 0x54 | 'T' | | | 0x4b | 'K' | | | | THEN | ` }` | | 0x0642 | CR | ` Exec("CR");` | | 0x1690 | EXIT | `}` | ## 文件 游戏包含 3 个文件 * STARA.COM 和 STARB.COM:两者都包含游戏数据和存储在其自己的目录结构中的游戏可执行文件。 * STARFLT.COM:此文件是 DOS 可执行文件,包含初始化例程、通用 Forth 例程以及用于读写 STARA.COM 和 STARB.COM 中磁盘数据结构的例程。 ## STARA.COM 和 STARB.COM 中的目录 STARA.com 的内容 | 条目 | 大小 | 描述 | |-----------|--------|-------------------------------------------------| | DIRECTORY | 4096 | 包含 STARA 和 STARB 的目录 | | ELO-CPIC | 4816 | | | GAZ-CPIC | 3120 | | | MEC-CPIC | 2848 | | | MYS-CPIC | 6064 | | | NOM-CPIC | 1136 | | | SPE-CPIC | 1888 | | | THR-CPIC | 2480 | | | VEL-CPIC | 4672 | | | VPR-CPIC | 1248 | | | MIN-CPIC | 2096 | | | SPLASH | 16384 | 图片 | | MED-PIC | 2048 | 图片 | | PHAZES | 6144 | | | HUM-PIC | 480 | 图片 | | VEL-PIC | 432 | 图片 | | THR-PIC | 272 | 图片 | | ELO-PIC | 608 | 图片 | | AND-PIC | 640 | 图片 | | SAVE | 124000 | | | MUSIC | 4960 | 代码覆盖 | | EARTH | 1152 |球地图 | | GALAXY | 6304 | | | CREDITS | 16384 | 图片 | | COP-CPIC | 2928 | | | FONTS | 768 | | | CGA | 3600 | CGA 显卡的机器码例程 | | EGA | 3600 | EGA 显卡的机器码例程 | STARB.com 的内容 | 条目 | 大小 | 描述 | |--------------|--------|-----------------------------------------------------| | DIRECTORY | 4096 | 包含 STARA 和 STARB 的目录 | | INSTANCE | 150528 | 包含游戏大部分内容的树结构 | | BOX | 1024 | 表 | | BANK-TRANS | 144 | 表 | | CREWMEMBER | 128 | 表 | | VESSEL | 1936 | 表 | | ELEMENT | 544 | 表 | | ARTIFACT | 1584 | 表 | | PLANET | 1360 | 表 | | SPECIMEN | 448 | 表 | | BIO-DATA | 448 | 表 | | TPORT-PIC | 2416 | 图片 | | BPORT-PIC | 3984 | 图片 | | ANALYZE-TEXT | 3200 | 表 | | BUTTONS | 944 | 表 | | ICON1:1 | 912 | | | ICON1:2 | 912 | | | ICON1:4 | 912 | | | ICON-NAME | 736 | | | DPART-OV | 1552 | 代码覆盖 | | REGIONS | 176 | 表 | | CREATURE | 17024 | 表 | | CHKFLIGHT-OV | 960 | 代码覆盖 | | FRACT-OV | 4640 | 代码覆盖 | | ICONP-OV | 832 | 代码覆盖 | | SITE-OV | 1888 | 代码覆盖 | | HYPERMSG-OV | 4112 | 代码覆盖 | | GPOLY | 368 | | | FACET | 288 | | | VERTEX | 416 | | | BLT-OV | 864 | 代码覆盖 | | MISC-OV | 1440 | 代码覆盖 | | BANK-OV | 1520 | 代码覆盖 | | ASSCREW-OV | 2800 | 代码覆盖 | | PERSONNEL-OV | 4192 | 代码覆盖 | | SHIPGRPH-OV | 2112 | 代码覆盖 | | CONFIG-OV | 3072 | 代码覆盖 | | TDEPOT-OV | 4800 | 代码覆盖 | | PORTMENU-OV | 3120 | 代码覆盖 | | VITA-OV | 3552 | 代码覆盖 | | HP-OV | 4832 | 代码覆盖 | | LP-OV | 5280 | 代码覆盖 | | SENT-OV | 4784 | 代码覆盖 | | TV-OV | 3472 | 代码覆盖 | | COMM-OV | 7232 | 代码覆盖 | | COMMSPEC-OV | 2864 | 代码覆盖 | | SEED-OV | 2400 | 代码覆盖 | | LISTICONS | 720 | 代码覆盖 | | MOVE-OV | 3808 | 代码覆盖 | | ENGINEER | 2320 | 代码覆盖 | | DOCTOR | 1280 | 代码覆盖 | | ORBIT-OV | 6640 | 代码覆盖 | | CAPTAIN | 5952 | 代码覆盖 | | SCIENCE | 3952 | 代码覆盖 | | NAVIGATR | 880 | 代码覆盖 | | SHIPBUTTONS | 1984 | | | MAP-OV | 4160 | 代码覆盖 | | HYPER-OV | 7168 | 代码覆盖 | | ANALYZE-OV | 2560 | 代码覆盖 | | LAUNCH-OV | 1360 | 代码覆盖 | | FLUX-EFFECT | 464 | | | OP-OV | 4400 | 代码覆盖 | | ITEMS-OV | 6016 | 代码覆盖 | | LSYSICON | 752 | | | MSYSICON | 448 | | | SSYSICON | 176 | | | BEHAV-OV | 5360 | | | CMAP | 1008 | | | INSTALL | 800 | | | HEAL-OV | 1232 | 代码覆盖 | | REPAIR-OV | 1696 | 代码覆盖 | | GAME-OV | 5920 | 代码覆盖 | | PLSET-OV | 2400 | 代码覆盖 | | MAPS-OV | 2240 | 代码覆盖 | | VES-BLT | 4528 | | | STORM-OV | 1232 | 代码覆盖 | | COMPOUNDS | 176 | 表 | | IT-OV | 1936 | 代码覆盖 | | COMBAT-OV | 6192 | 代码覆盖 | | DAMAGE-OV | 2752 | 代码覆盖 | | LAND-OV | 1088 | 代码覆盖 | | PSTATS | 64 | 表 | | STP-OV | 1440 | 代码覆盖 | ## 用法 将原始 Starflight 游戏的文件放入文件夹 `starflt1-in` 和 `starflt2-in` 中,然后运行 `make`。你应该会得到两个可执行文件(`disasOV1` 和 `disasOV2`),它们会在文件夹 `starflt1-out` 和 `starflt2-out` 中生成内容。生成的输出是本仓库的一部分。
标签:1986, Binary Systems, CRPG, DNS解析, DOS游戏, Starflight, 二进制分析, 云安全运维, 云资产清单, 复古游戏, 太空探索, 客户端加密, 开源项目, 星际飞行, 沙盒游戏, 游戏开发, 游戏机制分析, 游戏源码研究, 游戏设计, 游戏逆向, 游戏重制, 经典游戏, 计算机历史, 软件考古, 逆向工具, 逆向工程