Assignment: TRICKY

Changelog:

  • 3 Feb 2025: mention that objdump won’t show space between segments
  • 7 Feb 2025: fix last fclose in example code to use correct FILE*

Your Task

  1. Create a C or Python program called infect1.c or infect1.cc or infect1.py (which will be run and get input and write output as described below) that takes reads target1.exe and produces a modified copy of that executable. When the modified exectuable is run, instead of outputting:

    Initialize application.
    Begin application execution.
    Terminate application.
    

    it should produce the output:

    Initialize application.
    You have been infected with a virus!
    Begin application execution.
    Terminate application. 
    

    and still run the same application code as the original version of target1.exe. (That is, your modified version should replace or add to the existing code in target1.exe, not replace target1.exe with a new program. If we supplied an alternate version of target1.exe with the same layout but slightly different application code, your infect1 program should still work on that modified version without changing the different application code.)

    Given a copy of infect1.c (or infect1.cc) and target1.exe, we should be able to do:

    gcc -Os -o infect1 infect1.c
    ./infect1 target1.exe target1-infected.exe
    chmod +x target1-infected.exe
    ./target1-infected.exe
    

    (or the same with g++ for .cc files, adding an option like -std=gnu++17 if needed) and see the output above. Similarly, given infect1.py, we should be able to do:

    python3 infect1.py target1.exe target1-infected.exe
    chmod +x target1-infected.exe
    ./target1-infected.exe
    

    We have given what we hope are useful hints below on one way to accomplish this (but you are welcome to find alternate strategies).

    If you would like, it is permissible for your infect program to run objdump or readelf.

  2. Repeat this process for target2.exe (with a program called infect2.c or infect2.cc or infect2.py)

    If you end up writing one program that works for multiple cases, you may submit the same infect program multiple times.

  3. Repeat this process again for any one of target3a.exe or target3b.exe (with a program called either infect3a.c, infect3a.cc, infect3a.py, infect3b.c, infect3b.cc, infect3b.py, as appropriate).

  4. In a file called answers.txt, briefly answer the following questions. (We do not expect more than a sentence or two for the first two questions.)

    1. How did you identify the file offsets in the target executables to overwrite (for the jump and and for the virus code)?

    2. How did you produce the machine code to insert for the jump to the virus code?

    3. If your infect programs have a hard-coded offsets or something similar, how would you automate finding the locations in target executables to overwrite so that it would work on other target programs?

  5. Upload your files to the submission site.

Hints

Differences between the target programs

  1. target1 has a large, obvious cavity for inserting the virus code and a substantial amount of space for inserting the jump.

  2. target2 has no large obvious cavity for inserting the virus code and is otherwise essentially the same as target1.

  3. target3a has less obvious space for inserting the jump to the virus code.

  4. target3b is a position-independent executable version of target2

General advice

  1. You can divide this task into two parts:

    • writing and insert code for the “virus” (that prints out “You have been infected with a virus!”)
    • add a jump to the virus code you inserted
  2. To simplify the assignment, you can hardcode the input and output file names in your infect program. That is, infect1.c opens and reads target1.exe and opens and writes target1-infected.exe.

  3. You canuse the utility objdump or Ghidra or similar to examine the executable target.exe. Such tools can also help you determine the machine code of the instructions you wish to insert. (You can create an small assembly file and assemble it, then examine the result.)

  4. A very useful program to examine the file is a hex editor; examples on Linux (and available via NoMachine on the department machines) include ghex.

Encoding a jump operation

  1. One way to encode a jump to AddressOfVirusFunction is using:

    pushq $AddressOfVirusFunction
    ret
    

    This results in 6 bytes of machine code on x86-64. This encodes the address of the function as an absolute value, rather than relative to the location of the push or ret. This means that the machine code relies on the virus function always being located at the same place in memory (true for target1, target2, and target3a, but not target3b), but the machine code does not change depending on where the jump is inserted.

  2. Alternately one could also encode a jump using a conventional jump instruction:

    jmp AddressOfVirusFunction
    

    The machine code for this will encode the address relatively (as an offset from the address of the jump instruction), so you will need to change the machine code based on where it is inserted. But, unlike the push + ret technique, this techhnique creates position-independent machine code.

  3. A push of a 32-bit constant (on 32- or 64-bit x86) can be encoded as an 0x68 byte followed by the (little-endian) constant. A ret is encded as c3.

    A jump can be encoded as an 0xe9 byte followed by a (signed) 32-bit offset from the address of the following instruction, or by a 0xeb followed by a (singed) 8-bit offset. (To use the 8-bit offset version, the offset will need to be between -128 and 127 bytes.)

Placing the jump/returning to the application

  1. Identify where the constant stings “Initialize appliation.” and “Begin application execution.” are referenced to locate relevant parts of the application code.

  2. A problem with placing either version of the jump is that:

    1. we need to do whatever the code that was replaced with the jump so the application still functions normally; and
    2. we need to make sure the virus code returns back to the application afterwards.

    When there’s room, a convenient solution for this is to replace a function return with a jump.

    This allows us to solve issue 2 by having the virus code end with a ret instruction that will substitute for the original code’s ret.

  3. It seems, however, like it would be a problem that a ret instruction in x86-64 is one byte but either version of our tricky jump is at least 5 bytes. However, often there is padding after a function return like:

      400661:       c3                      retq       
      400662:       66 66 66 66 66 2e 0f    data32 data32 data32 data32 nopw %cs:0x0(%rax,%rax,1)
      400669:       1f 84 00 00 00 00 00 
    

    The instruction at address 0x400662 is an unreachable no-op instruction, which is inserted only because the compiler and/or linker wanted to ensure that the next function started at an address that was a multiple of something. This is a “cavity” that gives a virus writer some room to work.

  4. If there is not room after the ret, one strategy is to replace the ret and a few of the instructions beforehand, but copy those extra instructions into the virus code. For example, given original code like:

      401287:       31 c0                   xor    %eax,%eax
      401289:       5d                      pop    %rbp
      40128a:       41 5c                   pop    %r12
      40128c:       c3                      ret
    

    one might prepend the xor and two pop instructions to the virus code then overwrite these four instructions with a jump instruction to the modified virus code.

    A challenge in implementing this approach is that one needs to identify instruction boundaries. For example, replacing c0 5d 41 5c c3 instead of 31 c0 5d 41 5c c3 would result in mangled instructions — what was intended to be the opcode of a jmp would instead be the ModRM byte of an xor.

  5. To insert both the virus code and the jump itself, you must determine the location in the executable to insert them, which is not the same as address at which they will be loaded in memory.

    For non-position-independent executables, the location that objdump and Ghidra and most similar tools most prominently show is the expected memory address. * One option is to look at the program headers object dump can output to figure out what offsets in the executable are loaded into what bytes of memory. * Another option is to pass options to objdump to get it to display the offset of code within the executable file. * Another option is to get a hexadecimal dump of the raw file and look for bytes shown in objdump output in the actual executable file to find their location. * Yet another option would be for your infect.c to search for particular bytes in the executable file itself.

Writing virus code

  1. The “virus” code we want you to insert could be written as follows (assuming the strategy of replacing a ret instruction with the jump to this code; you will need to modify this if you use a different strategy):

    virus:
        movl $1, %eax  // 1 = SYS_write
        movl $1, %edi  // system call first argument = stdout
        leaq string(%rip), %rsi // system call second argument = string
                                // using leaq in case virus code is at high address
        movl $37, %edx // system call third argument = length of string
        syscall
        retq
    string: 
        .asciz "You have been infected with a virus!\n"
    

    This code assembly is carefully written to avoid dependencies on its location in the executable.

    (This code makes a Linux system call to write a string to stdout. We could also have made a function call to the puts function by calling the puts@plt stub, but the location of puts@plt varies.)

    (When using the above assembly, if you redirect the infected executable’s output to a file instead of sending it to the terminal normally, you may see the output in an unexpected order: This assembly code outputs via a write system call directly and the application code uses buffered stdio.h functions. When stdout isn’t a terminal, stdio.h’s buffering will not write lines of output immediately. For this assignment, we only require you to support stdout being a terminal, so you should not need to worry about this issue.)

  2. You can put this assembly in a .S file and then use something like

    gcc -c file.S
    

    to get an .o file. You can then examine this .o file with objdump --file-offset -d file.o or similar.

Find space for the virus code

  1. Look for a large area of nops in the disassembly to determine where to insert the virus code. Record the address of this location in memory to generate the “tricky jump” code you will insert elsewhere in the executable.

  2. In target1.exe, there is a large segment of padding (nops) between functions that is a suitable place to put the virus code.

  3. In target2.exe and both variants of target3, there is not any obvious space to put a non-trivial amount of virus code based on the disassembly. But you can take advantage of how programs are loaded in “page” sized units. On our x86-64 Linux systems, pages are 4096 bytes (= 0x1000 bytes), and typically whole pages of an executable are loaded into memory.

    For example, consider the following program headers, which are similar to those you’ll see in target2:

    LOAD off    0x0000000000000000 vaddr 0x0000000000400000 paddr 0x0000000000400000 align 2**12
         filesz 0x0000000000000520 memsz 0x0000000000000520 flags r--
    LOAD off    0x0000000000001000 vaddr 0x0000000000401000 paddr 0x0000000000401000 align 2**12
         filesz 0x00000000000005d5 memsz 0x00000000000005d5 flags r-x
    LOAD off    0x0000000000002000 vaddr 0x0000000000402000 paddr 0x0000000000402000 align 2**12
         filesz 0x0000000000000218 memsz 0x0000000000000218 flags r--
    LOAD off    0x0000000000002e10 vaddr 0x0000000000403e10 paddr 0x0000000000403e10 align 2**12
         filesz 0x0000000000000240 memsz 0x0000000000789e78 flags rw-
    

    The load instructions use offests 0x0, 0x1000, 0x2000 and target addresses 0x400000, 0x401000, 0x402000 because these align with the beginning of 4096 byte pages in memory and the executable file. Because this is done there are some additional cavaties in the executable file: for example, the second load instruction only loads bytes 0x1000 through 0x15d5 of the executable and the third loads bytes 0x2000 through 0x2218, so apparently bytes 0x15d5 through 0x2000 are not used.

    Although these bytes appear to be unused and unloaded from the program headers, they are actually loaded because of how x86-64 Linux memory management works. For example though the program headers above request to load 0x5d5 bytes from ofsets 0x1000 through 0x2000 into address 0x401000 through 0x4015d5:

    LOAD off    0x0000000000001000 vaddr 0x0000000000401000 paddr 0x0000000000401000 align 2**12
         filesz 0x00000000000005d5 memsz 0x00000000000005d5 flags r-x
    

    because x86-64 Linux manages memory in page-sized chunks, it implements this by loading 0x1000 bytes from the file into addresses 0x401000 through 0x402000. It doesn’t know how to load less than 0x1000 bytes.

    This means that — even if the “LOAD” program header is not modified — data placed at offset 0x15d6 will be loaded into memory the same as data placed at offset 0x1500 would be because it’s part of the same page.

    Because objdump decides how much to output based on the program headers — without accounting for this rounding — it generally won’t show data within the “cavity”.

File I/O

  1. We are reading and writing binary files—not textfiles. You may need to open files in binary mode, next text mode.

In Python

  1. To open files in binary mode use something like open('input', 'rb') (for reading) or open('output', 'wb') (for writing). If you want to manipulate the file contents as an array, reading into a bytearray is one strategy. For example, this program copies from input.dat to output.dat, but changing bytes bytes 10–14 (indexed starting with 0) of the file.

    with open('input.dat', 'rb') as in_fh:
        data = bytearray(in_fh.read())
    data[10:14] = b'\xFF\xFE\xFD\xFC'
    with open('output.dat', 'wb') as out_fh:
        out_fh.write(data)
    

In C

  1. To read from and write to a binary file in C, you can use fopen, fread, and fwrite. You can run man fopen, man fread, etc. to get documentation for how these functions are called, or search online. An example usage of a program that copies “input.dat” to “output.dat” is the following:

     #include <stdio.h> 
     #include <stdlib.h>
    
     int main(void) { 
         FILE *in;
         FILE *out;
         char *buffer;
         int size;
         in = fopen("input.dat", "rb");
         /* get size of input.dat, by 
            moving to the end of the file */
         fseek(in, 0, SEEK_END);
         size = ftell(in);
         /* then, return to the
            beginning of the file */
         fseek(in, 0, SEEK_SET);
         buffer = malloc(size);
         fread(buffer, 1, size, in);
         fclose(in);
         out = fopen("output.dat", "wb");
         fwrite(buffer, 1, size, out);
         fclose(out);
     }