Sitemap

The EXE File Format

8 min readFeb 16, 2025

--

When MS-DOS was released the EXE file format was introduced to overcome COM file limitations. As written in the past article, the COM file format is a simple image of initialized code and data, loaded in a single segment of memory. A COM file when loaded doesn’t provide any extra information to the operating system, which find a suitable segment of memory, creating a PSP (Program Segment Prefix), and transfer the content from your disk to the memory, to transfer the control at offset 0100h.

According to a myth, Bill Gates said at the time that 640Kb of memory would be enough for everyone, but, if this were true, why we are here to talk about another executable file format?

According to the MS-DOS Programmer’s Reference Manual (or System Programmer’s Guide, depending on which copy you can find), an EXE file has a header at beginning of the file.


+--------+------+------------------------------------------------------+
| Offset | Size | Description |
+--------+------+------------------------------------------------------+
| 00H | 2 | Signature Word. 'MZ', the initials of Mark Zibowski. |
| 02H | 2 | Last Page Size. |
| 04H | 2 | File Pages. This word contains a count of the number |
| | | of pages required to hold the file. |
| 06H | 2 | Relocation Items. |
| 08H | 2 | Header Paragraphs. |
| 0AH | 2 | The minimum no. of paragraphs the program requires to|
| | | begin execution. |
| 0CH | 2 | The maximum no. of paragraphs that the program would |
| | | like allocated to it before it begins execution. |
| 0EH | 2 | Initial SS value. |
| 10H | 2 | Initial SP value. |
| 12H | 2 | Complemented Checksum. |
| 14H | 2 | Initial IP value. |
| 16H | 2 | CS initial value. |
| 18H | 2 | Relocation table offset. |
| 1AH | 2 | Overlay number. |
+--------+------+------------------------------------------------------+

The MZ signature is available from MS-DOS 1.0, and this is the initial EXE file format created. During the year this format has undergone several changes, mainly to support Microsoft Windows New Executable file format, PE file format (32-bit Version introduced with Windows NT 3.1 and used on Windows 95) and PE+ file format introduced for the 64-bit version.

A DOS file format, like the NE file format (New Executable file format, introduced to support Windows 1.0 in MS-DOS 3.2/3.3) contains 16-bit code which, obviously, is always anchored to the limit of 64kb per segment.

Simply put, a function can call/jump other code using the relative instructions rel8/rel16 (call/conditional jump/absolute jump/return) only in case the destination address is inside the same segment (a 64Kb of contiguous memory) and the offset is in the range ±32Kb (16 bit, 1 bit reserved for sign). Beyond this limit, the only option available on 16-bit is provided by FAR address opcode.

When an EXE file is loaded in memory, the operating system will look for a region of memory large enough to contain the file, or at least the minimum number of paragraphs needed (a paragraph is a 16 byte region of memory which is the minimum amount of memory in a segment).

Having said that, obviously it is necessary to correct all those instructions that refer to a different segment.

Let’s say we need to implement a memset function, and we have different buffers of same number of elements but different size..

#define ARRAY_SIZE 8192

char c_array[ARRAY_SIZE]; // 8kb of memory
short s_array[ARRAY_SIZE]; // 16kb of memory
long l_array[ARRAY_SIZE]; // 32kb long are 32-bit, int 16-bit
double d_array[ARRAY_SIZE]; // 64kb .. double are 64-bit

void far memset(const void far *dst, int pattern, size_t size)
{
char far *ptr = (char far *)dst;
for(size_t i = 0; i < size; i++) *ptr++ = (char)pattern;
return;
}

int main()
{
memset(&c_array, 0, sizeof(c_array));
// .....
return 0;
}

The first 3 array (c_array, s_array and l_array) are 56Kb totally, they can be allocated in the same segment, but for d_array, a full segment of 64Kb is required. In this case, a generic memset function needs as argument a far pointer, a memory address consisting of 2 elements, the segment and the offset.

If the memset function is using STOS opcode, the ES register will be used, and the address must be loaded in ES:DI.

void far memset(const void far *dst, int pattern, size_t size)
{
asm {
push bp
mov bp, sp

push es
push di

mov ax, ss:[bp+2] // load the offset of dst
mov di, ax
mov ax, ss:[bp+4] // load the segment of dst
mov es, ax
mov ax, ss:[bp+6] // load pattern
mov cx, ss:[bp+8] // load size
pushf // save flags register
cld // clear direction
rep stosb // transfer al into es:di until cx is 0 increasing di at
// every cycle
popf // restore flag register
pop di
pop es
mov bp, sp
pop bp
retf
}

int main()
{
// memset....
asm {
push 0x2000 // size in bytes
xor ax, ax
push ax // 0 as pattern
mov ax, DATASEG // place-holder for the segment
push ax
lea ax, offset c_array // offset of C in the segment
push ax
call far memset
}
}

DATASEG is a “place-holder”, created by the compiler to identify the segment where the variable is hold, and offset is the position inside this segment.

Inside an object file any symbol (function or code) is identified through a symbol table where the full name is stored..

Let’s say our program is composed by 3 files..

  • data.c where the first 56Kb are stored
  • large.c for d_array
  • code.c for memset and main function

The compiler generate 3 object files, putting in data.obj and large.obj the initialized data (and their size) for the variables, plus their names in the symbol table and in code.obj, a place holder inside the main for c_array and memset (even memset is in the same file, we used the far pointer modifier in the function declaration).

The linker is responsible of collecting all files, sorting by their content (code segment, data segment, stack segment), start to create the binary keeping track of each segment and offset for every symbol.

When this job is done, finally the place-holder used by the compiler will be replaced by the segment/offset (starting from 0!).


+--------+---------+--------+-------------------------+
| Offset | Segment | Size | Description |
+--------+---------+--------+-------------------------+
| 00H | 0 | 20H | main function |
| 20H | 2 | 20h | memcpy function |
| 40H | 4 | e000h | c_array/s_array/l_array |
| E040H | E04 | 10000h | d_array |
+--------+---------+--------+-------------------------+

A virus that will want to infect this file must add his code at the end of the file, or adding a new object file between two object files.

Looking at the EXE header file, as you may have noticed, there are two elements that may sounds familiar, the relocation table and the overlay number.

The relocation table is an array of N elements (where N is the Relocation Items defined in the header) stored in the relocation table (located at Relocation Table Offset) containing all addresses to be relocated, all place-holders of instruction that refer to a segment

+--------------------+
| instruction |
+--------------------+
| mov reg, CODESEG |
| mov reg, DATASEG |
| mov reg, STACKSEG |
| call seg:offset |
| jmp seg:offset |
+--------------------+

The difficult part of move the virus code between two segment is that the relocation table does not provide any information about the segment size (it’s a flat file, a contiguous block of code and data aligned at 16-bytes), so the only way to identify a segment is checking all relocation entries which give us information about where the segments are located (segment * 16), or, like in a COM file, append the code at the end, increasing the Load Image Size (File Pages field) in the header.

Get cod’s stories in your inbox

Join Medium for free to get updates from this writer.

There are two more aspects to consider in an EXE file:

  • Data at end of file
  • Overlay

About the data at end of file, let’s take for example PKZIP. It was possible to create a self extractor with PKZIP, and obviously the archive can be more of 1Mb. In this case the operating system loads only the number of pages specified in the header, not everything. In this case, the virus writer had two options:

  • Skip this file because it was not possible to infect
  • Injecting a new segment at end of the loaded image, shifting all extra data (and in a system with small amount of memory, it means, open the file in “a+b”, allocate the extra space on storage, jump to the original end of file — page n, read a page, jump to new end of page — size, write and repeat until everything is shifted).

Overlay are different.. They are a world apart! As I keep repeating, in the original architecture the memory limit is 1Mb, but not all megabyte is available for your application.

Press enter or click to view image in full size
My BOCHS setup.. I miss my Olivetti PCS-11!

Inside my Bochs machine, I’m running MS-DOS 6.22, and as driver I loaded only SETVER (which is used to show MSDOS 5.0 to some applications… compatibility mode!), HIMEM.SYS (my first post on Medium was about himem.sys, if you like read it!) and DOSKEY (macro, history etc.. nothing fancy compared to a modern shell).

As you can see from mem command, there are 616Kb of conventional memory available, but that doesn’t mean they are contiguous. So, technically, I don’t have any idea what’s the biggest EXE file I can load into the machine.

The overlays, and later the format NE (I wish it was used only for Windows) were used to bypass those limitations.

If we were to look for a comparison in the modern world, You can think of overlays as the DELAY IMPORT TABLE present in a PE file.

An overlay is an extra segment (code or data, it doesn’t matter at all) which can be loaded when required, which in simple words it means the EXE file has its own segments, plus other segments that can be loaded in a second moment.

Obviously everything in the standard library will always have to be available, and the application must be designed so that these extra blocks do not have to be loaded all at once in order to work.

Infecting an executable file was not a challenge in itself, but combining different infection techniques, obfuscation, hooking, etc. into a single virus was, keeping in mind that just like today, yesterday too antivirus were still used tools.

At the time, between one thing and another, after the first virus infection in our computer, my brother was obsessed by antivirus:

  • nav: Norton Anti-Virus
  • cpav: Central Point Anti-Virus, I remember it created a file with checksum of scanned/cleaned file, to speed-up the detection!
  • msav: Microsoft Anti-Virus, I guess introduced with release 6.0
  • scan: The McAfee Anti-Virus
  • tbav: ThunderByte Anti-Virus, first time I saw a heuristic engine
  • Dr Solomon Antivirus, distributed at the time by a magazine that had mistakenly distributed an infected floppy disk in the previous issue
  • ksav: A first version of Kaspersky Anti-Virus
  • itav: ITAV PRO, an italian product disappeared.
  • virit: Virit by TG-Soft.

Now, another reason why I’m writing all this is that nowadays we go too fast, and everything becomes obsolete in a short time.

References

--

--

cod
cod

Written by cod

virus (now malware…) since 1993