First steps in delinking
(This post is part of a series on the subject of my hobby project, which is recreating the C source code for the 1989 game F-15 Strike Eagle II by reverse engineering the original binaries.)
As things stand today, looking around the first game executable which I’m reconstructing (START.EXE
), it seems like there’s more than I don’t know about the game then I do know, especially when looking at the contents of the data segment. Some data is obvious (strings), some I have figured out by examining routines and figuring out what the data referenced in those routines are for, but the vast majority is a bunch of meaningless values (or no values for the uninitialized data/BSS, but we’ll get to that later). Today, I’m trying to do something about it.
A significant chunk of the game’s code is made of the standard C library subroutines, which I have identified a long time ago using IDA’s feature of routine signatures. They begin at offset 0x5542
with the start
routine which is the startup (aka crt0) code that serves as the entrypoint to the program and ends up calling main()
. Other libc subroutines follow until an unnamed subroutine (not public so the name likely got stripped, but it’s still recognized as belonging to libc by IDA) ends at offset 0x6a5b
, which is also the end of the first code segment (there are two).
Knowing about these subroutines means less work for me because I don’t need to analyze them to know what they are doing, or reimplement them. They also serve as an useful anchor for the actual game routines - if a game routine calls strcpy()
, that means I can understand what it’s doing on a higher lever, and also infer something about the data that’s used as arguments for that function.
But the libc functions also come with data of their own (initialized and uninitialized) that is merged with the game data to form the complete executable, with no apparent way to tell it apart. If I knew where it was, I could also ignore it while analyzing the disassembly, and also I would know what to skip when trying to reconstruct the contents of the data segment. If I ever want to get an identically reconstructed executable, I need to know where it begins and ends, so that when the final result is linked with libc again, I would not end up with duplicate data. So let’s try to figure it out.
The inspiration for this came from reading this blog where the author uses some clever tricks to undo linking with the help of Ghidra and a custom extension, the end result being functional object files which can be linked with custom code again to form a patched game executable. That way it might be possible to excise ancient APIs, provide modern replacements, instrumentation etc., otherwise using the game logic as is. But that approach is used with MIPS code for a PSX game, and it’s not clear whether it could be adopted to x86 code, especially in real mode. I won’t be doing anything as ambitious, but I will try to figure out where exactly the linker put the libc data.
The plan is as follows:
- Create a threadbare program with some sentinel data values but without anything from libc, inspect its layout
- Add the startup code from libc, record any change in layout
- Add the exact same subset of libc functions that the game uses, see what changed again
- Assuming that the same subset of functions results in the same order of code and data in the executable, cross-reference the information obtained from 1-3 with the layout of the game’s data segment and try to spot the libc data
First step, an exe with no libc
I’m starting with compiling a trivial C program with no libc at all. Such a program is useless, since the C main()
function will not work unless the startup code ran first to set things up - C might be a simple and low-level language, but it still needs minimal run-time support. Luckily, I don’t need the sample programs I’m building today to be functional, just so long as they link.
I encountered the __acrtused
symbol (remember, the C compiler adds one underscore to all names) before, it’s a tag that the linker uses to determine that the startup code is already present. What it is doesn’t really matter as long as it’s there. So let’s build this. I’m using the /NOD[EFAULT]
option to the linker to prevent linking with libc, which would happen by default, because the object file hello.obj
contains an entry for a default library to link with after it’s been compiled. The remaining /M /I
cause the linker to generate a map file and verbose output.
ninja@dell:eaglestrike$ make hello cl /Gs /c /Foe:\hello.obj hello.c link /M /I /NOD hello.obj,d:\hello.exe,,; Microsoft (R) Overlay Linker Version 3.65 Copyright (C) Microsoft Corp 1983-1988. All rights reserved. **** PASS ONE **** HELLO.OBJ(hello.c) **** LIBRARY SEARCH **** **** ASSIGN ADDRESSES **** LINK : warning L4021: no stack segment 1 segment "_TEXT" class "CODE" length 10H bytes 2 segment "_DATA" class "DATA" length 12H bytes 3 segment "CONST" class "CONST" length 0H bytes 4 segment "_BSS" class "BSS" length 0H bytes 5 segment "c_common" class "BSS" length 6H bytes **** PRINT MAP **** **** PASS TWO **** HELLO.OBJ(hello.c) **** WRITING EXECUTABLE **** ls -l build-f15-se2/hello.exe -rw-r--r-- 1 ninja ninja 559 Dec 23 2023 build-f15-se2/hello.exe
This executable file clocks in at 559 bytes, of which 512 is the MZ exe header. As expected, it’s pretty lean, so let’s look at it in IDA:
No major surprises there, there is no startup code, so the data segment is not set up properly. IDA was unable to find it, and it displays the executable as being all in one segment. The foobar
string is the first data item placed by the linker at offset 0x10 (which would be DS:0 in a functional executable). Then comes the initialized barfoo
value of 0xabcd
, preceeded by a single null byte, probably for purpose of alignment (it disappears when I make foobar
one character shorter). Last as expected is the string foobaz
, and the uninitialized values were placed at DS:0x22 and DS:0x24 as can be seen from the code in start
(which is actually main()
). Those might be confused as colliding with “something_else” at 0x20, but keep in mind that the data segment’s origin is actually 0x10 in this display, so they are well past the string.
The only minor surprise is the alignment. I confirmed that the null byte is not part of the object file data by using the dmpobj
utility from the OpenWatcom compiler:
ninja@dell:eaglestrike$ dmpobj build-f15-se2/hello.obj [...] LEDATA(a0) recnum:19, offset:0000011eh, len:0011h, chksum:7eh(7e) Seg index:2 offset:00000000h 00000000 68|65 6c|6c 6f|5f 63|5f 64|61 74|61 00 <hello_c_data.> ✅ foobar and the null terminator LEDATA(a0) recnum:20, offset:00000132h, len:0015h, chksum:edh(ed) Seg index:2 offset:0000000eh 0000000e cd|ab 73|6f 6d|65 74|68 69|6e 67|5f 65|6c 73|65 <..something_else> ✅ barfoo (no preceeding null byte) and foobaz 0000001e 00 <.>
A thing to note for the future is that null bytes that are not part of the original data may be apparently placed in the result by the linker.
Adding in the startup code
Looking good, wonder what will happen when I link in the actual libc startup code? For that I need to remove the __acrtused
tag and either get rid of the /NOD
option, or spell the name of the appropriate library file (slibce.lib
for small memory model and software emulated floating point) on the linker command line - I choose the latter.
ninja@dell:eaglestrike$ make hello cl /Gs /c /Foe:\hello.obj hello.c link /M /I /NOD hello.obj,d:\hello.exe,,slibce.lib; ls -l build-f15-se2/hello.exe -rw-r--r-- 1 ninja ninja 2325 Dec 24 2023 build-f15-se2/hello.exe
That makes for a hefty increase to 2325 bytes. Let’s look inside.
15 routines (and a “chunk” which in IDA speak is a disconnected part of a routine) made it into the executable. The unnamed (sub_...
) ones are also recognized as part of libc by IDA, they are referenced by the named ones, just lost their names to stripping.
The real meat of this part of the experiment comes from the contents of the data segment. It opens with two null bytes, then a word value that’s referenced from start
, that is the crt0 code. More null bytes follow, then the MSC library watermark which I know from the game, followed by 0x11, 0x0… and my code’s data begins. After it ends, more libc data continues. The initialized libc data ends with some error strings, and an apparent termination marker of 3 times 0xff. Following that are 11 (0xb) bytes of unitialized data before the two uninitialized variables of my own, and the data segment concludes.
Adding the remaining functions from libc
Here’s where it gets even more interesting. I look over the 63 items identified as originating from libc by IDA in the game’s executable, 5 of which are chunks. Out of the remaining ones, the __
-prefixed ones are internal routines, which were not likely referenced directly, but were pulled in as dependencies. That leaves us with a small subset of actual libc functions that the game uses. I don’t really care about their signatures, and neither does the linker, so it will suffice to declare them as whatever and attempt to call them from my main()
.
This yields an executable 7707 bytes long. I look inside again and compare the list of routines placed in the executable by the linker, but something is wrong. Routines were pulled in which are not present in the game, like itoa()
. I check to see where it is used in my experimental executable:
It’s used in fclose()
, also a libc function, so it seems like it tagged along as a dependency. Looking at the same location within fclose()
inside the game, it looks like it’s calling a different routine that’s not part of libc. Apparently, Microprose provided overrides of standard library functions. It’s probably a vestige of the codebase’s roots in older versions of the MSC compiler, where some of these functions might have not been available, so the devs rolled their own and forgot to remove them when switching to a newer compiler. Or maybe they figured they could do better? In the end, it seems like the following library functions were replaced:
Trying to link this however surprisingly fails:
link /M /I /NOD hello.obj,d:\hello.exe,,slibce.lib; Microsoft (R) Overlay Linker Version 3.65 Copyright (C) Microsoft Corp 1983-1988. All rights reserved. **** PASS ONE **** HELLO.OBJ(hello.c) **** LIBRARY SEARCH **** C:\msc510\lib\SLIBCE.LIB(dos\crt0.asm) C:\msc510\lib\SLIBCE.LIB(dos\crt0dat.asm) C:\msc510\lib\SLIBCE.LIB(dos\crt0msg.asm) C:\msc510\lib\SLIBCE.LIB(crt0fp.asm) C:\msc510\lib\SLIBCE.LIB(chkstk.asm) C:\msc510\lib\SLIBCE.LIB(chksum.asm) C:\msc510\lib\SLIBCE.LIB(dos\stdargv.asm) C:\msc510\lib\SLIBCE.LIB(dos\stdargv.asm) : error L2044: __setargv : symbol multiply defined, use /NOE [...]
That is very strange, why is the symbol multiply defined? I would expect the linker to only pull in functions from the library which could not be resolved locally, yet it seems to be grabbing the library version while it has my replacement readily available, and the two clash? Using /NOE
does indeed let it link successfully, but it has unwanted side effects in that the order of libc routines within the executable changes. Before overriding the libc functions and adding /NOE
, both my hello.exe
and the game executable have libc routines arranged in this order (of course, the offsets differ):
seg000:0056 ; [000000B2 BYTES: COLLAPSED FUNCTION start. PRESS CTRL-NUMPAD+ TO EXPAND] 🟡 the startup (entrypoint) routine seg000:0108 ; [000000C4 BYTES: COLLAPSED FUNCTION __cinit. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:01CC ; [00000017 BYTES: COLLAPSED FUNCTION _exit. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:01E3 ; [00000045 BYTES: COLLAPSED FUNCTION __exit. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:0228 ; [0000002D BYTES: COLLAPSED FUNCTION __ctermsub. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:0255 ; [0000000F BYTES: COLLAPSED FUNCTION sub_10255. PRESS CTRL-NUMPAD+ TO EXPAND] 🟡 two unnamed unknown functions recognized as part of libc by IDA seg000:0264 ; [00000013 BYTES: COLLAPSED FUNCTION sub_10264. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:0277 align 2 seg000:0278 ; [00000020 BYTES: COLLAPSED FUNCTION __FF_MSGBANNER. PRESS CTRL-NUMPAD+ TO EXPAND] [...]
After I do the override and add /NOE
, it changes to the following:
seg000:0062 ; [0000016C BYTES: COLLAPSED FUNCTION start. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:01CE ; [00000027 BYTES: COLLAPSED FUNCTION _fopen. PRESS CTRL-NUMPAD+ TO EXPAND] 🔴 functions that I called get placed after `start` seg000:01F5 align 2 seg000:01F6 ; [000001E6 BYTES: COLLAPSED FUNCTION _fread. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:03DC ; [0000013C BYTES: COLLAPSED FUNCTION _fwrite. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:0518 ; [0000007A BYTES: COLLAPSED FUNCTION _lseek. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:0592 ; [0000002B BYTES: COLLAPSED FUNCTION _strcmp. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:05BD align 2 seg000:05BE ; [00000004 BYTES: COLLAPSED FUNCTION _getche. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:05C2 ; [00000017 BYTES: COLLAPSED FUNCTION _getch. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:05D9 align 2 seg000:05DA ; [0000001E BYTES: COLLAPSED FUNCTION _movedata. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:05F8 ; [0000000D BYTES: COLLAPSED FUNCTION _inp. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:0605 align 2 seg000:0606 ; [0000000F BYTES: COLLAPSED FUNCTION _putch. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:0615 align 2 seg000:0616 ; [00000015 BYTES: COLLAPSED FUNCTION _abs. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:062B align 2 seg000:062C ; [00000011 BYTES: COLLAPSED FUNCTION _srand. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:063D align 2 seg000:063E ; [00000026 BYTES: COLLAPSED FUNCTION _rand. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:0664 ; [0000009C BYTES: COLLAPSED FUNCTION __aNldiv. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:0700 ; [00000034 BYTES: COLLAPSED FUNCTION __aNlmul. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:0734 ; [000000A2 BYTES: COLLAPSED FUNCTION __aNlrem. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:07D6 ; [00000022 BYTES: COLLAPSED FUNCTION unknown_libname_1. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:07F8 ; [000000C4 BYTES: COLLAPSED FUNCTION __cinit. PRESS CTRL-NUMPAD+ TO EXPAND] 🔴 this used to come right after `start` seg000:08BC ; [00000017 BYTES: COLLAPSED FUNCTION _exit. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:08D3 ; [00000045 BYTES: COLLAPSED FUNCTION __exit. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:0918 ; [0000002D BYTES: COLLAPSED FUNCTION __ctermsub. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:0945 ; [0000000F BYTES: COLLAPSED FUNCTION sub_10945. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:0954 ; [00000013 BYTES: COLLAPSED FUNCTION sub_10954. PRESS CTRL-NUMPAD+ TO EXPAND] seg000:0967 align 2 seg000:0968 ; [00000020 BYTES: COLLAPSED FUNCTION __FF_MSGBANNER. PRESS CTRL-NUMPAD+ TO EXPAND]
I did some experiments, and the order in which _fopen
-_fread
-_fwrite
-… are placed in the executable doesn’t appear to be influenced by the order of calling or declaration in the C code. However, what I think happens here is that the order in which the linker pulls in individual object files from libc can be influenced by “some” factors, and this is what happened with /NOE
. What this option does exactly is not documented at all in the compiler or linker documentation, nor is the associated L2044
linker error. It took some web searching to uncover this ancient bit of wisdom hidden in an old KB article:
The /NOEXTDICTIONARY switch tells the linker NOT to take advantage of additional information recorded in Extended Dictionary in the library file. This additional information describes which module in the library calls any other module from the same library, saving linker number of passes through the library file to pick up all required modules.
If you have a call in your code to the library function FOO and FOO calls another function BAR from the same library, then at processing time of FOO, the linker will pull out BAR. This process occurs because the extended dictionary has a link between FOO and BAR.
Linking without /NOE causes the following error if you want to pull FOO in from the library but you want to provide its own BAR:
L2044 BAR : symbol multiply defined, use /NOE
This error resulted from the linker pulling FOO and BAR from the same library, then later it sees BAR coming from user .OBJ file.
Using /NOE in this case prevents the linker from pulling out BAR from the library, so your BAR routine is used instead.
If you have genuine symbol redefinition, then when linking with /NOE you will see the following error:
L2025 BAR : symbol defined more than once
So what I think happened here is that before I added /NOE
, things like __cinit
and _exit
were pulled in early as dependencies of start
. Once I added it, the early processing did not happen, which is why they were pulled in later – presumably after the entire hello.obj
object file was processed and symbols were still found missing.
It’s kind of weird that it takes an undocumented switch to make the linker operate in a way that’s considered standard nowadays, but I guess back then it was not common to use libraries other than the ones shipping with the compiler (the docs don’t even mention how to create libraries, it’s handled by a separate tool (LIB.EXE
) and covered in the “CodeView and Utilities” document) and linking took a long time, so it made sense to try and optimize it. In any case, I got the idea to add an explicit call to exit()
in the source code as a way to force the compiler to pull it earlier. Surprisingly, that was actually the golden ticket which put all the functions in the exact same order as they have in the game executable. Victory!
ninja@dell:eaglestrike$ make hello cl /Gs /c /Foe:\hello.obj hello.c link /M /I /NOD /NOE hello.obj,d:\hello.exe,,slibce.lib; ls -l build-f15-se2/hello.exe -rw-r--r-- 1 ninja ninja 6855 Dec 30 2023 build-f15-se2/hello.exe
The executable is a little short of 7kB in size now. That’s the overhead of libc on the game, about 15%. But now let’s look at the data segment.
Within the initialized data, not much has changed. Starting from the top we have:
- Some null bytes from the startup code
- The Microsoft libc watermark followed by
0x11, 0x0
- My data
- The remaining (non-startup) libc initialized data terminating with 3x
0xff
. Not surprisingly, there’s more of it than last time.
The problem lies in the layout of the BSS (uninitialized) section:
dseg:0346 db 0FFh dseg:0347 db 0FFh dseg:0348 db 0FFh 🟢 libc initialized data ends here, BSS begins dseg:0349 db 407h dup( ?) ; 😭😭😭 dseg:0750 word_11CD0 dw ? ; x dseg:0752 word_11CD2 dw ? ; y dseg:0754 db 20Ch dup( ?) dseg:0754 dseg ends
Compared to last time (where there were 11 bytes of BSS with my data placed last), now the BSS consists of two featureless blocks with the sentinel data laying square in the middle, at a seemingly random offset.
I was really hoping for something simple like the 11 bytes of startup code again, followed by my data, with the libc data coming last, in a size that I could match to a block of ?
s at the game’s BSS’ end. No such luck.
This seems like little return for a lot of work, but I’ve got some ideas to follow up with:
- do more investigation into the libc code, surely it must reference the BSS data, it’s weird that IDA doesn’t have any references to it, maybe it just needs some manual tagging somewhere
- try removing all the functions and readding them one by one, try to match BSS regions with libc modules
- keep reversing the game, marking up all uninitialized data used by the game means what remains comes from libc - the hard way
This will have to wait for the next time next time though, since this post is already too long.