loading...
Cover image for Virus 101: Don't be suspicious

Virus 101: Don't be suspicious

cpu profile image Daniel McCarney Updated on ・8 min read

VeXation (6 Part Series)

1) VeXation - Let's write a virus 2) Win95 Virus dev: Getting set up 3 ... 4 3) Win95 File Infector Virus Basics 4) A VXer's Best Friend: the Delta Offset 5) Using Win95 kernel32.dll exports like a virus. 6) Virus 101: Don't be suspicious

Welcome back! If this is your first visit to VeXation you may want to start at the beginning.

Continued Recap

At the end of the last post I had completed apisafejector, a self-replicating position independent PE infector virus for Windows 95 that avoids hard-coded Win32 API addresses. While apisafejector is a real improvement over earlier versions and iterations of the WIP virus it still has one large flaw: infected programs no longer work correctly!

Today I'll describe how I fixed this flaw and updated the virus so that the original program code is executed after propagating the infection.

The problem at hand

One of the steps that apisafejector takes in order to get the appended virus code to be executed when an infected program is run is changing the AddressOfEntryPoint in the infected executable's PE header.

; eax -> SecHdrVirtualAddress of .ireloc section, a RVA
mov (IMAGE_NT_HEADERS [ecx]).OptionalHeader.AddressOfEntryPoint, eax

"Peering inside the PE" describes this field as:

DWORD AddressOfEntryPoint

The address where the loader will begin execution. This is an RVA, and can usually be found in the .text section.

In the case of apisafejector the virus stomps an infected program's true AddressOfEntryPoint RVA, replacing the one that pointed into the .text section with one that points into the .ireloc section where the injected virus code is. Importantly the injected virus code never invokes the program's real executable code that lays dormant in the .text section.

From a user experience perspective this means infected programs appear broken - they don't do anything when they are run (except silently infect other executables of course). This kind of side-effect is unnecessarily destructive and sure to bring attention to the virus prematurely.

Confirmation

To get myself back into the swing of things I verified the application breaking behaviour of apisafejector with tdump, td32 and an infected calc.exe.

In brief, my process was to:

  • Build apisafejector without debug symbols using make.
  • Use make run to infect a copy of calc.exe by running apisafejector.exe in td32 to completion.
  • Use tdump to verify calc.exe was infected.
  • Copy the infected calc.exe into a temp directory alongside an uninfected cdplayer.exe
  • Use tdump to verify that the clean cdplayer.exe was not infected yet.
  • Run the infected calc.exe. Nothing appears to happen. No calculator appears.
  • Use tdump again to show that cdplayer.exe has become infected.
  • Run cdplayer.exe to show that it appears to be broken too. No CD-player UI appears.

A direct solution

Suspiciously breaking infected applications is a bug in the virus code that is in dire need of fixing. I went with a straight forward solution for preserving the original function of infected programs:

  1. Saving the original AddressOfEntryPoint value when an executable is infected into the virus code that is injected into the infected program.
  2. Returning execution to the AddressOfEntryPoint saved in the virus code when the infected program is done propagating the infection.

Saving the original entry RVA

If you remember the previous delta offets post then you already know that the virus code and the virus variables are all in the same PE section. That makes everything self-contained and easier to inject into a new executable.

Another happy side-effect of this approach is that it's easy for one generation of the virus to "pre-populate" variables for the next generation of the virus. When the executing virus code copies itself into the .ireloc section of the target executable it will copy its variables with their current values in-tact.

Crucially this means that if the virus saves the original AddressOfEntryPoint value of a victim program before it injects itself and stomps the entry point then it will be accessible to the injected virus code later on.

Jumping to the original entry RVA

With the true entry RVA of the infected program accessible the virus code can go about redirecting execution back to the original program by jmping to it.

In practice there is one extra wrinkle in the plan: the original entry RVA is just that, a Relative Virtual Address. It isn't an absolute address that the virus code can jmp right to. Instead it's an offset relative to the address the executable was loaded at by the operating system's loader.

In order to figure out the absolute address to jmp to the virus code needs to be able to find out what address the operating system happened to load the executable that it is running out of (often called the base address). I decided to use the win32 GetModuleHandleA function exported by kernel32.dll to find this. By providing a NULL value for the lpModuleName argument GetModuleHandleA returns the base address of the executing .exe.

The last VeXation post laid the ground work for reliably calling exported kernel32.dll functions from the virus code which made it straight-forward to use GetModuleHandleA to find the executable's base address. By combining the base address with the saved original entry RVA the virus code has an absolute address to jmp to so the infected program can start to run normally.

Complete Assembly

To start working on a solution to the entry point problem I used the apisafejector project code as a base, copying it into a new directory called epjector (short for "entry-point injector" I guess?) and renaming files/includes accordingly. The majority of my changes are in epjector.asm.

To begin with I added two new variables to the _data label at the end of the virus code to save entry point RVAs. DD is "define double word" and results in a 4 byte variable, matching the size of the DWORD AddressOfEntryPoint PE header field.

; Original entry-point of to-be infected .exe (or null in generation 0)
originalEntryPoint  DD 0

; Address we return control flow to after infecting (copy of original entry
; point)
savedEntryPoint     DD 0

Next I updated the @@analyzepe code to copy the AddressOfEntryPoint value of the PE file being considered for infection into the originalEntryPoint variable before it is overwritten (respecting the delta offset in ebp of course).

; At this point we've decided we have found a valid i386 PE and we can
; analyze it for infection.
@@analyzepe:
  ; Copy the original entrypoint address somewhere safe
  mov ecx, (IMAGE_NT_HEADERS [eax]).OptionalHeader.AddressOfEntryPoint
  mov [ebp + originalEntryPoint], ecx

To populate savedEntryPoint I added some new logic at the very start of the virus code immediately after calculating the delta offset:

; If this is not generation 0 then the originalEntryPoint variable will have
; been set when the currently executing PE was infected. We need to stash that
; somewhere we can JMP to later. We'll be writing over originalEntryPoint when
; we find a target to infect and propagate another generation.
@@saveoep:
    mov eax, [ebp + originalEntryPoint]
    mov [ebp + savedEntryPoint], eax

The last task is refactoring the code labelled findfirst and findnext to find the correct absolute address to jmp to using the savedEntryPoint RVA when there are no more .exes to infect.

Previously if FindFirstFileA returned INVALID_HANDLE_VALUE (-1) or if FindNextFileA returned 0 then the virus code would jmp to the error label to exit the process. I refactored the epjector version of this logic to instead jmp to a @@nofirst or @@nonext label that invokes CALL_OEP. For example:

@@nofirst:
  CALL_OEP

In generation 0 things are slightly different than in subsequent generations. Generation 0 has no "original" functionality to return execution to after infection. For all other generations the savedEntryPoint variable holds the RVA that the operating system loader would have used if the program wasn't infected.

I chose to implement finding the absolute address for the saved entry-point RVA and jumping to it as a macro called CALL_OEP, defined in macros.inc.

The macro checks the delta offset stored in ebp to decide if the currently executing virus code is generation 0 or not. If it is generation 0 then the macro evaluates to being the same as the old apisafejector behaviour, a jmp to the error label. For generations 1+ the macro evaluates to code that will jmp to the correct absolute address using the savedEntryPoint.

; CALL_OEP is a macro for calling the savedEntryPoint of the
; infected EXE. If called in generation 0 it is equivalent to
; a jmp to the error label because there is no saved entry
; point. When called in generation 1 the GetModuleHandleA API
; function from kernel32.dll is used to find the absolute
; address with the savedEntryPoint RVA.
CALL_OEP MACRO
    LOCAL @@notgenzero
    LOCAL @@genzero
    ; Use EBP to decide if this is gen > 0
    cmp ebp, 0h
    je @@genzero
@@notgenzero:
    ; When it isn't gen0 we need to jmp to OEP
    ; First calculate the base address of the infected PE
    CALL_RUNTIME_API GetModuleHandleA, <0h>
    ; Then add the saved OEP
    add eax, [ebp + savedEntryPoint]
    ; Bye bye! Give control to the non-viral code.
    jmp eax
@@genzero:
    ; When it is gen0 we don't have an OEP to
    ; jmp to. Instead just jmp to error and ExitProcess.
    jmp error
ENDM

A more subtle virus

I repeated the same process I used to confirm the apisafejector behaviour to verify that epjector successfully hides the fact that programs are infected by preserving their original behaviour.

For epjector the process was to:

  • Build epjector without debug symbols using make.
  • Use make run to infect a copy of calc.exe by running epjector.exe in td32 to completion.
  • Use tdump to verify calc.exe was infected.
  • Copy the infected calc.exe into a temp directory alongside an uninfected cdplayer.exe
  • Use tdump to verify that the clean cdplayer.exe was not infected yet.
  • Run the infected calc.exe. This time the calculator UI does appear!
  • Use tdump again to show that cdplayer.exe has become infected.
  • Run cdplayer.exe. It also works as intended and the CD player UI appears.

Conclusion

It has taken six posts (!) but I've finally arrived at an acceptable skeleton for
a PE file infector virus. It is definitely not a stealthy or sophisticated virus
but it:

  • Successfully self-propagates within a directory.
  • Doesn't hard-code any win32 API addresses.
  • Doesn't break the infected program.

There are a few directions I have in mind for future posts:

  • Building out a payload. The virus needs to do something besides propagate itself.
  • Improving the infection strategy. The virus should recurse outside of the current directory.
  • Discussing AV. I'd like to summarize the most glaring "stealth" problems with the current virus and share some results from running AV against it as-is.

As always, I would love to hear feedback about this project. It would also be useful to know if one of the above directions interests you more than others. Feel free to drop me a line on twitter (@cpu) or by email (daniel@binaryparadox.net).

VeXation (6 Part Series)

1) VeXation - Let's write a virus 2) Win95 Virus dev: Getting set up 3 ... 4 3) Win95 File Infector Virus Basics 4) A VXer's Best Friend: the Delta Offset 5) Using Win95 kernel32.dll exports like a virus. 6) Virus 101: Don't be suspicious

Posted on by:

cpu profile

Daniel McCarney

@cpu

I live in the woods and help write the free software that powers Let's Encrypt.

Discussion

markdown guide