Hacking & Tech

Tech Talk: Behind the curtain Obfuscating Linux Symbols

Obfuscating Linux Symbols: a novel approach to evade static analysis in Linux malware. Original security research from the Bulletproof.

Keiran Mather Headshot

Keiran Mather Red Team Specialist

11/07/2024 15 min read

Introduction

Obfuscating Linux Symbols: a novel approach to evade static analysis in Linux malware.

This is a Bulletproof Tech Talk article: original research from our red team covering issues, news, and tech that interests them. It’s more technical and in-depth that our usual blog content, but no less interesting.

This blog looks at obfuscating Linux Symbols using dl_iterate_phdr with callbacks. It represents original security research from the Bulletproof Red Team.

API hashing has long been used throughout Windows malware development. There are many publicly available proof-of-concepts (PoCs) for this technique, which helps evade static analysis by hiding entries from the Import Address Table on Windows. This technique is required to prevent defensive software from easily identifying the functionality of a potentially malicious application. For example the presence of known API calls used for process injection would be a big red flag of malicious intent. The same detection logic is now being applied on Linux based systems as EDRs continue to enhance detection capabilities on these platforms. This blog post and PoC aims to achieve that same result of obscuring used symbols that may indicate the payloads malicious functionality, by hiding entries from the Symbol Table, with the aim of bypassing static detection mechanisms used by Linux based EDR's.

Share this Article

Windows vs. Linux

A few weeks ago, I started exploring different methods to hide from the dynamic symbol table (.dynsym) within an ELF binary. It turns out the approach is quite similar to what you'd do on Windows, with a slight twist.

On Windows the high-level process is as follows:

  1. Get the base address of the library where our function of interest is, e.g, kernel32.dll.
  2. Locate the kernel32 Export Address Table (EAT).
  3. Iterate through each exported function name by the kernel32 module.
  4. For each exported function name, calculate its hash value and compare it against our own, then calculate the function's virtual address if there's a match.

On Linux the high-level approach is similar to Windows:

  1. Use the dl_iterate_phdr function to iterate over all shared objects loaded in the current process.
  2. For each shared object (or just libc), locate its symbol table (e.g, the dynamic symbol table (dynsym) section in the ELF file).
  3. Iterate through each symbol name in the symbol table of the shared object.
  4. For each exported symbol name, calculate its hash value using our hash function.
  5. If the calculated hash equals the target hash, then retrieve the symbol's address.

As you can see, the process is quite similar across both Windows and Linux.

What is dl_iterate_phdr?

The dl_iterate_phdr function is apart of the GNU C Library (glibc) and it is used to walk over the list of shared objects loaded in a process. It calls the callback function once for each object until all objects have been processed or if the callback returns a non zero value. Using this function allows us to inspect each shared object and view their internal structures, such as the dynamic symbol table, which is exactly what we want to do here.

This function takes the following two parameters:

  • A callback function that it will call for each shared object.
  • A pointer to predefined user data (void *data).

Here is a snippet taken from the man page of dl_iterate_phdr which demonstrates how this function is defined:

int dl_iterate_phdr(int (*callback)(struct dl_phdr_info *info, 
                                    size_t size, void *data),
                    void *data);

The callback function receives a dl_phdr_info struct, which contains information about the shared object. The struct looks like this, which is also a snippet taken from the man page:

struct dl_phdr_info {
    ElfW(Addr)        dlpi_addr;  /* Base address of object */
    const char       *dlpi_name;  /* (Null-terminated) name of object */
    const ElfW(Phdr) *dlpi_phdr;  /* Pointer to array of ELF program headers for this object */
    ElfW(Half)        dlpi_phnum; /* Number of items in dlpi_phdr */

    /* The following fields were added in glibc 2.4, after the first
       version of this structure was available.  Check the size
       argument passed to the dl_iterate_phdr callback to determine
       whether or not each later member is available.  */

    unsigned long long dlpi_adds;      /* Incremented when a new object may have been added */
    unsigned long long dlpi_subs;      /* Incremented when an object may have been removed */
    size_t dlpi_tls_modid;             /* If there is a PT_TLS segment, its module ID as used in TLS relocations, else zero */
    void  *dlpi_tls_data;              /* The address of the calling thread's instance of this module's PT_TLS segment, if it has one and it has been allocated in the calling thread, otherwise a null pointer */
};

As we see from the above structure, it contains the following information:

  • The base address of the shared object
  • The name of the shared object
  • A pointer to an array of ELF program headers in the object
  • The number of program headers

Also, as stated in the man page the ElfW() macro turns its argument into the name of an ELF data type suitable for the hosts hardware architecture. For example, on a 32-bit platform, ElfW(Addr) resolves to Elf32_Addr and on a 64-bit platform it resolves to Elf64_Addr. This just ensures that the dl_phdr_info struct correctly represents the underlying ELF format of the loaded shared objects, regardless of the hosts architecture.

Using dl_iterate_phdr

As I mentioned previously, we can use this function to loop through all the shared objects loaded in our current process. This allows us to get the names and base addresses of these shared objects. Here is the code that will achieve this for us:

// callback function to be called for each shared object
int callback(struct dl_phdr_info *info, size_t size, void *data) {
  if (info->dlpi_name && info->dlpi_name[0]) {
    printf("Shared object: %s @ %p\n", info->dlpi_name, (void *)info->dlpi_addr);
  }
  else {
    printf("Shared object: (null)\n");
  }
  return 0;
}

int main() {
  // iterate over the shared objects in the current process
  dl_iterate_phdr(callback, NULL);
  exit(EXIT_SUCCESS);
}

The dl_iterate_phdr function goes through all shared objects and calls the callback function for each one as stated previously, this can be observed below from running the program:

$ ./main
Shared object: (null)
Shared object: linux-vdso.so.1 @ 0x7ffe6d5cc000
Shared object: /usr/lib/libc.so.6 @ 0x7f4c3cfe8000
Shared object: /lib64/ld-linux-x86-64.so.2 @ 0x7f4c3d1f8000

The output above shows the shared objects and their corresponding base addresses. The null shared object is just our own program, this is because its always the the first entry in this list. However, we are currently only interested in libc.so.6 which provides us with the essential functions for our program.

Now that we have this information, we can continue to parse the section headers and locate the dynamic symbol table within the libc shared object.

Parsing Section Headers to locate the Symbol Table

To locate the symbol table, we will:

  • Use dl_iterate_phdr to iterate over all shared objects loaded in the current process
  • Identify the libc.so.6 shared object and map it to memory
  • Parse the ELF headers and section headers of lib.so.6 to locate .dynsym

We will start by using the dl_iterate_phdr function, which we have already explained, so I will not go into detail here. Below is the code that initiates the iteration and identifies the libc.so.6 shared object:

void locate_symtable(const char *obj_path);

// callback function to be called for each shared object
int callback(struct dl_phdr_info *info, size_t size, void *data) {
    if (info->dlpi_name && info->dlpi_name[0]) {
        printf("Shared object: %s @ %p\n", info->dlpi_name, (void *)info->dlpi_addr);
        if (strstr(info->dlpi_name, "libc.so")) {
            locate_symtable(info->dlpi_name);
        }
    }
    else {
        printf("Shared object: (null)\n");
    }
    return 0;
}

We added the locate_symtable function, which is called from the callback function when the libc.so shared object is identified. This function will map the ELF file into memory and read its headers to locate the symbol tables.

Here is the locate_symtable function:

// function to locate the symbol table in the shared object
void locate_symtable(const char *obj_path) {
    int fd = open(obj_path, O_RDONLY);
    if (fd < 0) {
        perror("open");
        return;
    }

    // map the ELF file into memory
    off_t file_size = lseek(fd, 0, SEEK_END);
    void *elf_base = mmap(NULL, file_size, PROT_READ, MAP_PRIVATE, fd, 0);
    if (elf_base == MAP_FAILED) {
        perror("mmap");
        close(fd);
        return;
    }

    // read ELF header
    ElfW(Ehdr) *ehdr = (ElfW(Ehdr) *)elf_base;

    // read section headers
    ElfW(Shdr) *shdrs = (ElfW(Shdr) *)((char *)elf_base + ehdr->e_shoff);
    const char *shstrtab = (char *)elf_base + shdrs[ehdr->e_shstrndx].sh_offset;

    // iterate over section headers to find the dynamic symbol table
    for (int i = 0; i < ehdr->e_shnum; i++) {
        if (shdrs[i].sh_type == SHT_DYNSYM) {
            const char *section_name = shstrtab + shdrs[i].sh_name;
            printf("  Found symbol table: %s\n", section_name);
        }
    }

    munmap(elf_base, file_size);
    close(fd);
}

Our locate_symtable function does the following:

  1. Open the shared object file in read-only mode
  2. Map the entire file into memory
  3. Read the ELF header to get information such as offsets to the section headers
  4. Reads the section headers and the section header string table to access section names and their attributes
  5. Iterates over all section headers to find the SHT_DYNSYM section

All in all, this will allow us to identify the dynamic symbol table in the libc.so.6, this can be seen below once compiled and run:

$ ./main

Shared object: (null)
Shared object: linux-vdso.so.1 @ 0x7ffe6d5cc000
Shared object: /usr/lib/libc.so.6 @ 0x7f4c3cfe8000
  Found symbol table: .dynsym <--- found dynamic symbol table
Shared object: /lib64/ld-linux-x86-64.so.2 @ 0x7f4c3d1f8000

Parsing and printing all symbols from the Symbol Table

Next, we'll expand upon our locate_symtable function to print all symbols found in the dynsym of the libc.so.6 shared object.

We will have to make a few changes for this to work:

  1. Read the symbol table and its corresponding string table.
  2. Determine the number of symbols in the table.
  3. Iterate the entries of the symbol table and print every symbol name.
void locate_symtable(const char *obj_path) {
    int fd = open(obj_path, O_RDONLY);
    if (fd < 0) {
        perror("open");
        return;
    }

    // map the ELF file into memory
    off_t file_size = lseek(fd, 0, SEEK_END);
    void *elf_base = mmap(NULL, file_size, PROT_READ, MAP_PRIVATE, fd, 0);
    if (elf_base == MAP_FAILED) {
        perror("mmap");
        close(fd);
        return;
    }

    // read ELF header
    ElfW(Ehdr) *ehdr = (ElfW(Ehdr) *)elf_base;

    // read section headers
    ElfW(Shdr) *shdrs = (ElfW(Shdr) *)((char *)elf_base + ehdr->e_shoff);
    const char *shstrtab = (char *)elf_base + shdrs[ehdr->e_shstrndx].sh_offset;

    // iterate over section headers to find the dynamic symbol table
    for (int i = 0; i < ehdr->e_shnum; i++) {
        if (shdrs[i].sh_type == SHT_DYNSYM) {
            const char *section_name = shstrtab + shdrs[i].sh_name;
            printf("  Found symbol table: %s\n", section_name);

            // read the symbol table
            ElfW(Sym) *symtab = (ElfW(Sym) *)((char *)elf_base + shdrs[i].sh_offset);
            const char *strtab = (const char *)elf_base + shdrs[shdrs[i].sh_link].sh_offset;
            int num_symbols = shdrs[i].sh_size / shdrs[i].sh_entsize;

            for (int j = 0; j < num_symbols; j++) {
                printf("    Symbol: %s\n", strtab + symtab[j].st_name);
            }
        }
    }

    munmap(elf_base, file_size);
    close(fd);
}

After identifying the symbol table section as we have done previously, we:

  1. Read the symbol table at shdrs[i].sh_offset and its corresponding string table at shdrs[shdrs[i].sh_link].sh_offset.
  2. Determine the number of symbols by dividing the section size by the size of each entry using shdrs[i].sh_size / shdrs[i].sh_entsize.
  3. Iterate through the symbol table entries and print the name of each symbol using the string table, as seen below:
$ ./main
Shared object: (null)
Shared object: linux-vdso.so.1 @ 0x7ffe6d5cc000
Shared object: /usr/lib/libc.so.6 @ 0x7f4c3cfe8000
  Found symbol table: .dynsym
    Symbol: 
    Symbol: _dl_argv
    Symbol: _dl_find_dso_for_object
    Symbol: __libc_enable_secure
    Symbol: _dl_deallocate_tls
    Symbol: __tls_get_addr
    Symbol: __libc_stack_end
    Symbol: _rtld_global_ro
    Symbol: _dl_signal_error
    Symbol: _dl_signal_exception
    Symbol: _dl_audit_symbind_alt
    Symbol: __tunable_is_initialized
    Symbol: _dl_rtld_di_serinfo
    Symbol: _dl_allocate_tls
    Symbol: __tunable_get_val
    Symbol: _dl_catch_exception
    Symbol: _dl_allocate_tls_init
    Symbol: _rtld_global
    Symbol: __nptl_change_stack_perm
    Symbol: _dl_audit_preinit
    Symbol: fgetc
    Symbol: pthread_attr_setscope
    Symbol: pthread_attr_getstacksize
    Symbol: envz_strip
    Symbol: pthread_attr_getstacksize
    ....[snip]....

Now that we are able to view all the symbols, this brings us closer to the objective of implementing symbol hashing for dynamic function resolution.

Generating a hash for the system function

To proceed, we need to generate a hash for the system function using a basic Djb2 hashing function:

unsigned long HASH(unsigned char *str) {
    unsigned long hash = 6543;
    int c;

    while ((c = *str++)) {
        hash = ((hash << 5) + hash) + c;
    }

    return hash;
}

int main() {
    unsigned char *function_name = (unsigned char *)"system";
    unsigned long hash = HASH(function_name);
    printf("hash of %s: %u\n", function_name, hash);

    return 0;
}

This will give us the hash of the string system which corresponds to: 2227611796

Implementing the hashing function

We can now implement this hashing function into our code and resolve the function if it matches the hash, we will do this by pre-defining our system hash which we obtained previously via the HASH hashing function, then compare all symbols to this hash, and if it matches, we know it is the system symbol.

Here is our updated code to reflect this:

// djb2 hash function
uint32_t HASH(const char *str) {
    uint32_t hash = 6543;
    int c;

    while ((c = *str++)) {
        hash = ((hash << 5) + hash) + c;
    }

    return hash;
}

// function to locate the symbol table in the shared object and resolve function addresses
void locate_symtable(const char *obj_path, void *base_addr) {
    int fd = open(obj_path, O_RDONLY);
    if (fd < 0) {
        perror("open");
        return;
    }

    // map the ELF file into memory
    off_t file_size = lseek(fd, 0, SEEK_END);
    void *elf_base = mmap(NULL, file_size, PROT_READ, MAP_PRIVATE, fd, 0);
    if (elf_base == MAP_FAILED) {
        perror("mmap");
        close(fd);
        return;
    }

    // read ELF header
    ElfW(Ehdr) *ehdr = (ElfW(Ehdr) *)elf_base;

    // read section headers
    ElfW(Shdr) *shdrs = (ElfW(Shdr) *)((char *)elf_base + ehdr->e_shoff);
    const char *shstrtab = (char *)elf_base + shdrs[ehdr->e_shstrndx].sh_offset;

    // system hash calculated from other snippet
    uint32_t target_hash = 2227611796;

    // iterate over section headers to find the dynamic symbol table
    for (int i = 0; i < ehdr->e_shnum; i++) {
        if (shdrs[i].sh_type == SHT_DYNSYM) {
            const char *section_name = shstrtab + shdrs[i].sh_name;
            printf("  Found symbol table: %s\n", section_name);

            // read symbol table
            ElfW(Sym) *symtab = (ElfW(Sym) *)((char *)elf_base + shdrs[i].sh_offset);
            const char *strtab = (const char *)elf_base + shdrs[shdrs[i].sh_link].sh_offset;
            int num_symbols = shdrs[i].sh_size / shdrs[i].sh_entsize;

            // iterate each symbol to check for a matching hash with system
            for (int j = 0; j < num_symbols; j++) {
                const char *sym_name = strtab + symtab[j].st_name;
                uint32_t sym_hash = HASH(sym_name);
                if (sym_hash == target_hash) {
                    printf("    Found target symbol: %s\n", sym_name);
                    // calculate the functions address (need to calculate this by also adding the base addr)
                    void *func_addr = (void *)((char *)base_addr + symtab[j].st_value);
                    printf("    Address of system: %p\n", func_addr);
                }
            }
        }
    }

    munmap(elf_base, file_size);
    close(fd);
}

// callback function to be called for each shared object
int callback(struct dl_phdr_info *info, size_t size, void *data) {
    if (info->dlpi_name && info->dlpi_name[0]) {
        printf("Shared object: %s @ %p\n", info->dlpi_name, (void *)info->dlpi_addr);
        if (strstr(info->dlpi_name, "libc.so")) {
            locate_symtable(info->dlpi_name, (void *)info->dlpi_addr);
        }
    } else {
        printf("Shared object: (null)\n");
    }
    return 0;
}

int main() {
    // iterate over the shared objects in the current process
    dl_iterate_phdr(callback, NULL);
    return 0;
}

What we have done at this point is:

  1. Added the Djb2 hash function to calculate the hash of each symbol
  2. Added the pre-defined system hash calculated from our Djb2 hashing calculator
  3. Modified our Symbol Table parsing functionality to compare hashes to the system hash, and resolve the function if the hash matches.

Executing the resolved function

Once we run the updated code, we can see that it finds the system address. This can be confirm via GDB as well:

gef➤  b *main+30
Breakpoint 1 at 0x15ef: file main.c, line 98.
gef➤  r

....</snip>....

Shared object: (null)
Shared object: linux-vdso.so.1 @ 0x7ffdf0fe9000
Shared object: /usr/lib/libc.so.6 @ 0x75d6d33a3000
  Found symbol table: .dynsym
    Found target symbol: system
    Address of system: 0x75d6d33f3f10 <--- system addr
Shared object: /lib64/ld-linux-x86-64.so.2 @ 0x75d6d35b3000

Breakpoint 1, 0x00005555555555ef in main () at main.c:98


gef➤  x/x 0x75d6d33f3f10
0x75d6d33f3f10 <system>:        0xfa1e0ff3

As the output from GDB shows, we have the address of system()!! Now, at this point, we really have everything set up in order to use this resolved function to execute a command, we can simply edit the code to do this. We will execute the uname command via system, it should be noted that this string will appear in the binary, but there are ways to get around this.

First, we define a function pointer that has the same function signature as system:
typedef int (*system_func)(const char *);

Then inside of the locate_symtable function, once we have found the address of system, we cast func_addr to the defined function pointer type, in our case it is system_func:
system_func _system = (system_func)func_addr;

Finally, we call the system function using the function pointer to execute the uname command:
_system("uname");

Overall, it looks something like this:

if (sym_hash == target_hash) {
    printf("    Found target symbol: %s\n", sym_name);
    // calculate the functions address (need to calculate this by also adding the base addr)
    void *func_addr = (void *)((char *)base_addr + symtab[j].st_value);
    printf("    Address of system: %p\n", func_addr);

    // define a function pointer type for system
    typedef int (*system_func)(const char *);
    system_func _system = (system_func)func_addr;

    // execute uname using the resolved system function
    _system("uname");
}

Once, executed we should have it output the value from uname:

$ ./main

Shared object: (null)
Shared object: linux-vdso.so.1 @ 0x7ffe1f9d3000
Shared object: /usr/lib/libc.so.6 @ 0x7028ce28e000
  Found symbol table: .dynsym
    Found target symbol: system
    Address of system: 0x7028ce2def10
Linux  <--- uname executed
Shared object: /lib64/ld-linux-x86-64.so.2 @ 0x7028ce49e000

We can now also check the symbols of this binary to show that system() is in fact not there. This binary was also compiled with symbols:

$ readelf -Ws ./main

Symbol table '.dynsym' contains 16 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND dl_iterate_phdr@GLIBC_2.2.5 (2)
     2: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@GLIBC_2.34 (3)
     3: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_deregisterTMCloneTable
     4: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND puts@GLIBC_2.2.5 (2)
     5: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND mmap@GLIBC_2.2.5 (2)
     6: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND printf@GLIBC_2.2.5 (2)
     7: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND lseek@GLIBC_2.2.5 (2)
     8: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND close@GLIBC_2.2.5 (2)
     9: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
    10: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND munmap@GLIBC_2.2.5 (2)
    11: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND open@GLIBC_2.2.5 (2)
    12: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND perror@GLIBC_2.2.5 (2)
    13: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_registerTMCloneTable
    14: 0000000000000000     0 FUNC    WEAK   DEFAULT  UND __cxa_finalize@GLIBC_2.2.5 (2)
    15: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND strstr@GLIBC_2.2.5 (2)

Symbol table '.symtab' contains 36 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS main.c
     2: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS
     3: 0000000000003de0     0 OBJECT  LOCAL  DEFAULT   21 _DYNAMIC
     4: 00000000000020ac     0 NOTYPE  LOCAL  DEFAULT   17 __GNU_EH_FRAME_HDR
     5: 0000000000003fe8     0 OBJECT  LOCAL  DEFAULT   23 _GLOBAL_OFFSET_TABLE_
     6: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND dl_iterate_phdr@GLIBC_2.2.5
     7: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@GLIBC_2.34
     8: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_deregisterTMCloneTable
     9: 0000000000004050     0 NOTYPE  WEAK   DEFAULT   24 data_start
    10: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND puts@GLIBC_2.2.5
    11: 0000000000004060     0 NOTYPE  GLOBAL DEFAULT   24 _edata
    12: 0000000000001608     0 FUNC    GLOBAL HIDDEN    15 _fini
    13: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND mmap@GLIBC_2.2.5
    14: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND printf@GLIBC_2.2.5
    15: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND lseek@GLIBC_2.2.5
    16: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND close@GLIBC_2.2.5
    17: 0000000000004050     0 NOTYPE  GLOBAL DEFAULT   24 __data_start
    18: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
    19: 0000000000004058     0 OBJECT  GLOBAL HIDDEN    24 __dso_handle
    20: 0000000000002000     4 OBJECT  GLOBAL DEFAULT   16 _IO_stdin_used
    21: 0000000000004068     0 NOTYPE  GLOBAL DEFAULT   25 _end
    22: 00000000000010d0    38 FUNC    GLOBAL DEFAULT   14 _start
    23: 000000000000120f   793 FUNC    GLOBAL DEFAULT   14 locate_symtable
    24: 0000000000004060     0 NOTYPE  GLOBAL DEFAULT   25 __bss_start
    25: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND munmap@GLIBC_2.2.5
    26: 00000000000015e9    31 FUNC    GLOBAL DEFAULT   14 main
    27: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND open@GLIBC_2.2.5
    28: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND perror@GLIBC_2.2.5
    29: 0000000000004060     0 OBJECT  GLOBAL HIDDEN    24 __TMC_END__
    30: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_registerTMCloneTable
    31: 0000000000001528   193 FUNC    GLOBAL DEFAULT   14 callback
    32: 00000000000011c9    70 FUNC    GLOBAL DEFAULT   14 HASH
    33: 0000000000000000     0 FUNC    WEAK   DEFAULT  UND __cxa_finalize@GLIBC_2.2.5
    34: 0000000000001000     0 FUNC    GLOBAL HIDDEN    12 _init
    35: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND strstr@GLIBC_2.2.5

As the output above from readelf suggests, there is no system() symbol present. This can also be seen when running strings against the binary. The only entry found relates to the printf() statement.

$ strings ./main | grep -i sys

Address of system: %p

Caveats

This will not work if the binary was compiled statically. The dl_iterate_phdr function is used to walk through a list of shared objects that are dynamically loaded into an executable, and since statically linked programs do not load the libc shared object, there is nothing for dl_iterate_phdr to iterate over.

Conclusion

By following the code snippets above this blog should provide you with the understanding to implement simple symbol hashing for your Linux based payloads. The code that was used within this blog is for demonstrative purposes to help understand the concepts alongside the walk through. A more complete proof of concept to accompany this blog for this can be found here. Hopefully this blog has proved useful and highlighted a potentially familiar approach to symbol hashing for Linux. As EDRs and prevention systems continue to advance on additional platforms such as Linux and MacOS we feel its important to spend time developing solutions to existing problems around payload development on different platforms.

Keiran Mather Headshot

Meet the author

Keiran Mather Red Team Specialist

Keiran’s role as a one of Bulletproof’s Red Team members, sees him analysing and investigating all kinds of technology. You can find him writing about novel hacking techniques, exploits, and other security testing matters.

Keep the hackers out with red team

Don’t let your business be breached. Find your security flaws with a red team assessment from Bulletproof.

Get started today

Related resources


Trusted cyber security & compliance services from a certified provider


Get a quote today

If you are interested in our services, get a free, no obligation quote today by filling out the form below.

(1,500 characters limit)

For more information about how we collect, process and retain your personal data, please see our privacy policy.