┌───────────────────────┐
▄▄▄▄▄ ▄▄▄▄▄ ▄▄▄▄▄ │
│ █ █ █ █ █ █ │
│ █ █ █ █ █▀▀▀▀ │
│ █ █ █ █ ▄ │
│ ▄▄▄▄▄ │
│ █ █ │
│ █ █ │
│ █▄▄▄█ │
│ ▄ ▄ │
│ █ █ │
│ █ █ │
│ █▄▄▄█ │
│ ▄▄▄▄▄ │
│ █ │
Creating polyglot ELF files for fun and anti-forensics │ █ │
~ dominikr └───────────────────█ ──┘
Introduction
------------
This paper is the continuation of "A deep dive into how the Linux kernel loads
executable files". We will take what we have learned from the first part, and
use it to create a unique new type of ELF file - one that can interpreted as two
different ELF files, depending on circumstances.
Throughout the paper, the names "ELF32" and "ELF64" are used, to mean ELF files
with 32-bit or 64-bit header data structures, respectively. If not otherwise
noted, e_machine == EM_X86_64 is assumed.
A polyglot file is defined as a file whose syntax matches two ore more file
formats. This paper uses the the term 'polylgot' and not 'ambiguous file', as
ELF32 and ELF64 are two related, but distinct file formats with well-defined
and differing data structures (see [3] for further discussion about
terminology)
Are polyglot ELFs possible?
---------------------------
I mentioned in the discussion of the previous paper how the e_phentsize field
"will likely contain garbage" if the kernel tries to load an x32 ELF as an
64-bit ELF.
What if... it doesn't? Could we create a file that is, at the same time, a valid
32-bit and 64-bit ELF file? Such a file would pose an interesting conundrum:
how do you parse such a file, when it is in both formats at the same time?
For the Linux kernel, we know the answer: it will try to execute it as an ELF64,
unless e.g. e_phentsize64 is wrong, or it has an invalid PT_INTERP header.
Almost all tools will just use the EI_CLASS and EI_DATA headers to identify the
ELF file.
While there are already polyglots of an ELF and other file types, to the best of
my knowledge nobody has ever created an ELF32 + ELF64 polyglot.
The polylgot ELF itself was created using the NASM assembler. Crafting an
executable file from scratch requires creating binary structures and
referencing them i.e. headers by byte offset, tasks which an assembler is
uniquely suited for. Plus, the assembler of course also allows us to insert
code into the appropriate places in the executable.
Key obstacles in creating polyglot ELFs
---------------------------------------
What follows is a walk through key parts of the NASM file used to generate
the polyglot ELF. The setpos macro is used to set an absolute file position
(or to fail with a fatal error if already past that point). It will fill up
the space with null bytes by default.
| setpos 0
| db 0x7F, "ELF" ; EI_MAG
| db 1 ; EI_CLASS / bits
| db 1 ; EI_DATA / endian
| db 1 ; EI_VERSION
| db 0 ; EI_OSABI
| times 8 db 0 ; EI_ABIVERSION, EI_PAD
|
| setpos 16
| dw 3 ; type
| dw 0x3e ; machine
| dd 1 ; version
The start of the file holds no big surprises: it is a standard ELF header,
and the ELF32 and ELF64 formats do not differ yet[4]. EI_CLASS is set to 1,
i.e. 32-bit ELF file. This will be further discussed below.
| setpos 24
| dd _start ; entry32 / entry64a
| dd phoff32 ; phoff32 / entry64b
| dq phoff64 ; shoff32,flags32 / phoff64
This is the first critical point in the creation of a polyglot ELF. The upper
half of the ELF64 entrypoint overlaps with the ELF32 program header offset.
That does not pose much of a problem, as we're free to choose the origin
addresses in both ELF variants.
Next, the ELF64 program header offset overlaps with the ELF32 section header
offset and flags register. We can easily ignore the flags, as they're not
really used on the x86 architecture, but the phoff64/shoff32 pose more of
a problem. If we want to use sections in ELF32, we have to deal with the fact
that at least 1 PHDR64 is overlapping with our sections.
| setpos 40
| dw 0x34 ; ehdrsiz32 / shoff64a
| dw 0x20 ; phdrsiz32 / shoff64b
| dw PHNUM ; phdrcnt32 / shoff64c
| dw SHENT ; shentsize32 / shoff64d
| dw SHNUM ; shnum32 / flags64a
| dw SHSTR ; shstrndx32 / flags64b
Here we have the other major critical point in our ELF header. The ELF64 section
header offset is overlapping with several crucial ELF32 headers. phdrsiz32 is
fixed to 0x20, and phdrcnt32 needs to be at least 1.
So, if we want to have a sections in the ELF64 part, the smallest offset
possible is 0x100200000 (if we also set ehdrsiz32 to 0), which is just over 4GB.
shentsize32 will also have to be set to 0, meaning we can't use sections in the
ELF32 part. If we want to have sections in both parts (i.e., keep shentsize at
0x28), the section header offset, and thus our minimal file size, would be a bit
over 11 terabytes.
While 4GB executables might be on the edge of a "reasonable" binary size
([2] talks about 25GB binaries at Google), 11TB is beyond that, and also
hits maximum file size limits for some filesystem (actual storage space
requirements would not be a problem, as those binaries could be saved as
sparse files).
So, to recap:
* We can have ELF32 sections, but the sections will overlap with PHDR64
* We can have ELF64 sections, at a minimum file size of 4GB, if we disable
ELF32 sections
* We can have ELF64 sections, at a minimum file size of 11TB, if we also want
to have ELF32 sections
Now, the goal was to create an ELF file that would be parsed differently by
the kernel and various tools. For creating a "proper" ELF file, we need to
have sections (because tools like objdump will not disassemble an ELF without
sections) - for the ELF variant that gets shown to the tools, at least.
This means that we have to go with an ELF32 for the tools, and ELF64 for the
kernel, if we want to have a small file size. Also, Ubuntu seems to have
disabled x32 support in their recent versions, which is another argument for
using ELF64 for the Kernel side.
In short: a file that looks like an ELF32 to the tools, but appears as an ELF64
to the kernel, seems to be the best decision.
Two distinct code paths
-----------------------
There wouldn't be much use in painstakingly creating a polyglot ELF if both
variants just executed the same code in the end (remember that entry32 and
the lower half of entry64 are at the same byte locations).
How do we achieve two distinct code paths if the entry address points at mostly
the same location? One idea would be to add some code that checks if we're
running as an x32 or 64-bit binary, but such code would stick out like a sore
thumb to any halfway decent forensic analyst.
The easiest way to achieve this is to have two different offsets in the
respective LOAD program headers. This means that the code will *load* at a
similar address, but the kernel will take the content from different
offsets in the file. The granularity of the program header offset field is
4096 in Linux - meaning that this is also a lower bound for our file size.
The two payloads are very simple: the x32 payload just does an exit(0).
One thing to note is that the assembly code itself is also a polyglot - it
will run in 32-bit, x32, and 64-bit mode (this can be verified by changing
the e_machine field to EM_386 or EM_486).
The ELF64 payload will print the string "EVIL" and exit.
Overlapping SHDR32/PHDR64
-------------------------
As discussed before, SHDR32 and PHDR64 overlap. Because we chose ELF64 for
the kernel side, we can keep it as bare as possible, especially if it helps
to create a more interesting 'fake' ELF32 to show to the tools. In this case
this means creating just a single PHDR64 entry, and create it in such a way
that it minimally interferes with the 32-bit section headers.
One thing to point out is that a PHDR64 entry is 56 bytes long, while a SHDR32
entry is 40 bytes. This means that the minimal PHDR64 with just one entry will
span over 2 SHDR32 entries.
According to the specifications[4], the first section header should only contain
null values. This is just not possible when also overlapping with a program
header, but care has been taken to keep as many values as possible at zero.
| sh_name 0x00 p_type == 1 (LOAD)
| sh_type 0x04 p_flags == 15 (exec)
| sh_flags 0x08 p_offset == 0x1000
| sh_addr 0x0c
| sh_offset 0x10 p_vaddr == 0x(phof32)00000000
| sh_size 0x14
| sh_link 0x18 p_paddr
| sh_info 0x1c
| sh_addralign 0x20 p_filesz == size + 0x100000000
| sh_entsize 0x24
| sh_name2 0x28 p_memsz == size + 0x100000000
| sh_type2 0x2c
| sh_flags2 0x30 p_align
| sh_addr2 0x34
The program header entry needs to be 1 (LOAD) to actually load any code, and the
least-significant bit of the flags needs to be 1, to mark the segment as
executable. p_flags was set to 15 - this corresponds to the FINI_ARRAY section
type. It was chosen because it can be parsed by readelf, without readelf trying
to read any other data structures. The sh_name value of 1 can't be changed, so
the string ".fini" was instead placed at position 1 of the section header string
table, to match with the sh_type.
For simple ELF files, p_filesz and p_memsz are often chosen to be the same (see
[1]). But - if sh_name2, which overlaps with p_memsz, is not null; and sh_type2
*is* null, then tools like eu-elflint will complain about "nonzero sh_name for
NULL section". To prevent that, the value of 0x100000000 was added to the 64-bit
size field, thus putting the number 1 in the 32-bit sh_entsize and sh_type2
fields. This means that eu-elflint will now no longer complain.
Similarily to the previous section header, a (nonensical, but plausible
sounding) name of ".cfi" has been put at position `size` of the section header
string table - or more precisely, at (shdr-$$)+size. `size`, in turn, had also
to be chosen as to not interfere with any other parts of the ELF file.
Finally, a value of 0x1000 had to be put in into the p_offset field. The only
way to prevent this would be to switch offsets around - putting the ELF32
code at offset 0x1000. But this would also lead to the ELF64 memory image
containing all the fake headers. This option was not further evaluated.
Other sections & headers
------------------------
While the previous chapter talked about specifics of creating a polyglot,
some other parts also have to be taken into account to create a normal looking
ELF file. As before, this only applies to the ELF32 part, as that is how the
file will be interpreted by most tools.
Namely, a ".text" section was added that points to the executable part. This
allows the use of "objdump -d", among other things. Header fields also have been
set to ensure maximum compatibility (e.g. EI_VERSION == 1, EI_OSABI == 0,
e_version == 1).
In the program header, p_paddr has been set to p_vaddr, and p_align has been
set to 0x1000, to match with how those fields are used in practice.
The final polyglot binary
-------------------------
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............
00000010: 0300 3e00 0100 0000 5000 0000 7000 0000 ..>.....P...p...
00000020: d000 0000 0000 0000 3400 2000 0100 2800 ........4. ...(.
00000030: 0400 0300 4000 3800 0100 4000 0000 0000 ....@.8...@.....
00000040: 4153 4d33 32ff ffff ffff ffff ffff ffff ASM32...........
00000050: ffc0 cd80 0000 0000 0000 0000 0000 0000 ................
00000060: 5048 4452 3332 ffff ffff ffff ffff ffff PHDR32..........
00000070: 0100 0000 0000 0000 0000 0000 0000 0000 ................
00000080: 5400 0000 5400 0000 0500 0000 0010 0000 T...T...........
00000090: 5354 5233 32ff ffff ffff ffff ffff ffff STR32...........
000000a0: 002e 6669 6e69 002e 7465 7874 002e 7368 ..fini..text..sh
000000b0: 7374 7274 6162 0000 0000 0000 0000 0000 strtab..........
000000c0: 5348 4452 3332 ffff 5048 4452 3634 ffff SHDR32..PHDR64..
000000d0: 0100 0000 0f00 0000 0010 0000 0000 0000 ................
000000e0: 0000 0000 7000 0000 0000 0000 0000 0000 ....p...........
000000f0: 0001 0000 0100 0000 0001 0000 0100 0000 ................
00000100: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000110: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000120: 0700 0000 0100 0000 0600 0000 0000 0000 ................
00000130: 5000 0000 0400 0000 0000 0000 0000 0000 P...............
00000140: 0000 0000 0000 0000 0d00 0000 0300 0000 ................
00000150: 0000 0000 a000 0000 a000 0000 af0f 0000 ................
00000160: 0000 0000 0000 0000 0000 0000 0000 0000 ................
*
000001a0: 2e63 6669 0000 0000 0000 0000 0000 0000 .cfi............
000001b0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
*
00001020: 4153 4d36 34ff ffff ffff ffff ffff ffff ASM64...........
00001030: ffc7 b001 b205 6845 5649 4c54 5ec6 4604 ......hEVILT^.F.
00001040: 0a0f 05b0 3c31 ff0f 0500 0000 0000 0000 ....<1..........
00001050: ebde ..
Major sections have been marked in-line with their name, to make it easier
to navigate the hexdump by sight.
The ASM32 section was placed right after the ELF header, to keep the entrypoint
address as low as possible, because the minimal file size will be the entrypoint
address + 4096, due to the offset of the 64-bit code.
If you look at offset 0x00000050 and 0x00001050, you can nicely see how the
entrypoints differ by exactly 0x1000 (4096 decimal). The ASM64 code jumps back
by a couple of bytes to 0x1030 on entry, to save a few bytes of file size.
The string ".cfi" can be seen at offset 0x000001a0 - this is the name of the
second section entry, as discussed before. It can also be seen that the
string table is at offset 0x000000a0 (see "STR32" name above). From this
it can also be easily calculated that `size` was chosen as 0x100 (256
decimal).
The above hexdump can be converted back into a binary file with the `xxd -r`
command.
Fun and anti-forensics
----------------------
All tested tools detect the file as an x32 binary, with the exception of the
`elfid` tool presented in the first paper of this series.
Tools tested include:
* file (the widely used open-source variant by Ian Darwin)
* GNU objdump
* GNU readelf
* eu-readelf
* eu-elflint
* Binary Ninja
* Radare2 / rabin2
* Ghidra
* gdb
Radare2, Binary Ninja, Ghidra and "objdump -d" all point to the fake x32
assembly if the binary is statically analyzed. The output from objdump is
shown as a representative example:
| poly: file format elf32-x86-64
|
|
| Disassembly of section .text:
|
| 00000000 <.text>:
| 0: ff c0 inc %eax
| 2: cd 80 int $0x80
gdb shows some interesting behaviours. When loading the polyglot, the command
`show architecture` will return the following:
| The target architecture is set to "auto" (currently "i386:x64-32")
But when trying to start the program with `starti`, it warns:
| warning: Selected architecture i386:x64-32 is not compatible with reported
| target architecture i386:x86-64
The program starts to execute, but e.g. only the lower 32-bits of all
registers will be shown with `info registers`.
gdb starts to work as expected if the architecture is manually set with the
`set architecture i386:x86-64` command.
The tool `eu-elflint` shows some warnings, but as already discussed, this is
largely unavoidable due to the SHDR32/PHDR64 overlap. No other tool
referenced so far shows any warnings about the ELF file.
| zeroth section has nonzero name
| zeroth section has nonzero type
| zeroth section has nonzero flags
| zeroth section has nonzero align value
| zeroth section has nonzero entry size value
| zeroth section has nonzero size value while ELF header has nonzero shnum value
As mentioned, the `elfid` tool is the only program to show any indication
that something fishy is going on:
| Polyglot detected
| Polyglot ELF wants you to think it's 32bit
| poly: ELF 32-bit LSB pie executable, x86-64, version 1 (SYSV)
Discussion & Conclusion
-----------------------
The question "are polyglot ELFs (ELF32+ELF64) possible?" can be be answered
with "yes", as practically shown.
Polyglot ELFs have proven to be a powerful anti-forensics technique. All
previously available tools were fooled by the polylgot ELF, showing information
and/or disassembly of the 'fake' ELF32 part.
This raises the follow up question of how such (ab)uses of the ELF format
could be prevented. That is not an easy question to answer - because both
the ELF32 and ELF64 parts are valid ELF files. For identifying a file as
either ELF32 or ELF64, each tool would basically have to exactly reimplement
the kernel's ELF loader - an insurmountable task, possibly also depending on
the exact kernel version and options.
An easier option would be to implement heuristics like in the `elfid` tool -
either warning users that an ELF might be a polyglot, and/or querying the
user if he wants to analyze a file as ELF32 or ELF64.
And last but not least, fun was also had [citation needed].
Future work
-----------
As with the previous paper, the research in this paper could also be extended
to other architectures and other operating systems.
The ELF file described is quite basic, and could be extended to e.g. further
mislead security tools like `checksec`, while keeping the 'real' part of the
executable free from constraints that such security check tools seek to check
and/or enforce. Such a binary has already been created, but there hasn't been
sufficient time available to create a third paper in the series yet.
References
----------
[1] https://www.muppetlabs.com/~breadbox/software/tiny/teensy.html
[2] https://fzakaria.com/2025/12/28/huge-binaries
[3] https://speakerdeck.com/ange/generating-weird-files-c691cdd5-ea89-4322-839a-29402da0f859
[4] https://refspecs.linuxbase.org/elf/elf.pdf
Notes about the assembler sources
---------------------------------
The sources for the assembler file used to create the polyglot ELF can be
found in Appendix B.
The source file itself is also a polyglot of a NASM assembly file and shell
script. Run it as `bash poly.asm` to create the polyglot and show some basic
information about it.
Run it as `bash poly.asm -DBIT=2` to create a variant with EI_CLASS set to
64-bit.
Appendix B - assembler sources to create the polyglot ELF
---------------------------------------------------------
base64 -d<<'E-O'|zcat>"poly.asm"&&echo OK
H4sIAe+Pasta+7VXbZPayBH+LP2KLgJV9q0EArTYgbpK9nzsmbr1rmtZ3+WSS7FCGiHZQpIlYbRO
5b/n6ZkRCOyrq0slFMvOdPf02/Tb9OKQHLMUFdk7kVEe5yL04sS8e/fwbaf7L6fX/+bfHTP1yi11
HbIz6nSB6uDfXzumH22zgC5qDZRsLoQZifopOcCqwvMF2UUaU3+ggWJniyRM4rRqH7WFWQgvAIbs
n70Gk5RkH7iJOq7MnkiDODTNGc1vrumPfPx8N6iecnEOLyuvqPQ6j4JilYXh73NjyjL+PPDTCrq8
CMm9JNcnd0L9/u9/nRE5Djlj/m3D65qcgEYOw9Wv/g6Pa2/NNIe139C3ucXv6UNC25SynD4WMJF2
n2hfU/8FlP1/f/LV0c3wJd+u/HzygqBoaFrrME5E+Vmtt2LbLClMNgeeXhJvUvO7xcOSJq7ZC0QY
p4KW81cPi7tbY3iA3F69mbe2b+9ufsHW7MVhChCBwQGHNVPqeDqceP39/XLx94lL5NTjl3SBDGlw
b65evV7czg0ghDn7EuqMjRltgrVFhRd4hRhRKgSuCogDj4df3s4NphuRqIVFYyojkAaUxOvB28Xc
7NWa8npxc2MEuOxZ7Q7bCt6+e2MMwSHdbdeigIsp4swpSjaz8YlpHJz0en77AO1GL9swZuK2AcuH
ewNqiqQUX5w9P2gPz086XziS9XDurq+X8wdjdDkx2w77Gzw8V/cefxb2szJCpeiDgd3tPkeERlWV
l9PBoMx2hS/28E8/KzaD9W7zOU4Sb1BG2X6FXd/fxH+Jg29H7ujFmCP7G0IB6fv0bB0GKyxX4Bun
m1VYZFu1F34VZ+nzKb2KhP+BwqwgjxSVSg1NQVXkVRSXlGYV3b67oUoU2zj1KhH0zXNLViiRE7ef
7pKEbJLWHB1xd7/4YXGLMIIJegPk1vOLDMKqPCtpaI/kdZtGFW9FSd2u3b3oDak3km6VtIczXHmI
77IQudFzyDQM3HscpNQbWjV2molLKnguHaZQ92oYQWCAsYTI+wKbrIJVxECGgSt9RWrJUofOHxA7
/l+ITb0taGRgc2qbhiwENJxYUozJCQL5Th2G5zgGaWktvgfOV8s3fF4K6GAzHnVMYyWbwdQ04tQ3
hFfzokLuvAQlx+f0KypyxThy4p1kxdc0Hk2PiTJHMrBjxu4IyZut3we7bT5YJ5lXPclCUaqYqzJa
C2Y4I66jFtdQSxVPS9VNS5dMS5VLbBNvU1q6SOLczd3V90oDY2hJwZaOvIuvbCpRV5yH7dWlBQcO
HcdpDFmdWU+N+cj+o/XYSONlCkz53uUlqUWnDz/EHYsBfRY11WBeazCOVd56quCSC7Ya9xv+Xyr/
n9S+mVYHKKkNXNi6iTJasZWsmQxrlbFSjSZ9LYLjEEjdrkUqKOzjkjUBodrAUfJbWw1fvgEOA/QQ
1I+cpwvU6KUMjD6UksVGd8ayo3qEunRcqgwCeYlVBE1ZTRzWOnmQq65+oBpl5OW5SPn01qv8iHY5
7eMqQkkLY5EEig1iqhAh+gPTlSfSmSDui/5UShuP7HVcqZzTdDHaZi010GVxyDYxsVJAimE8g8K4
wKQxcSUX9oCSvytlmY1laHM5haVeSqjkYuMlUpzF2Mgrz/1wcIB0hlIJAZFIH6930rE+mkQpvqAg
H8e1tMQrNuiUkiPT7aMsEdKTkFuiLzC7strlcUDrON1VMQZP9Jfpf9eJ/uS7OrJUYKkottUlji3H
au5T/uPA0o0EKMeqrSaOoNXytfaPKgVNossiIOuCSldM0x/gwzTURcAigbm0XbNOq2CrdCFKm/R9
VDH6Kyon/ynso6oAExcJFAS4f5rpCQ+DmIRcAoJZDYwGxBkO6EddPZhYhZom1s1wpuqZBuacn+OR
OsfsG9xhZGArwV+7yR7yWCYFyE8rsxVda/Y4p4QITTTTqSSVaEFlYGsga6OrqupuI9XeTnypZWsr
SNVL1Idu9xQH7sqTCmeq5o/RSXarF6jEHbxpOrzl2RQBHnM9wnupeRntIzArkd1JVjEZT4F8twhr
RdcAPyHbOVttHkcHTa9hCt1pYnneAenyp3tW6If5tUZ5uJsEiXC3vPpuYSqzX0qr4Qz9cmuUR7M1
g70cakm1K97qiZh96fmssslhcqLWOZ+RyzSqospc5Kpp04/z+9v5jXmMEeAaPw8U1cRdtwg/Nq5u
CGVUlurIQKYPAq2R6rJJcjKG1pcjg92p3nWQZOhzE9dj9Fii86+g14xWY7kiwJvwhMBnAjVKw7QI
qcnBBhMgnFAZXedIGyhaxayMMOMrVlJ1rYoauWeqfKRBfUKxlq8d+fJBEVJ2XnJu7UmGtzZQ+oX7
GCqoSMbsUPWKkJTHV9DBZJkRe53/ykgNklQHs+ROmqXRsghIS04BSnd+zakhUesqAeopganMbEYS
Q3zcGV2VObK1wAVtS1Wxangd2/9hFpMFTEezc0xiyGGyJhmfNSX5OQpHq4wgW4xO3w8xvuBgW3DP
K0seO5cPV/cPMP4wLVy4zp8njVlGg7bpmX4t6CFCbp5jxQWQ9TZ4Gp24PI1KXDONBiis2+wTplwM
J2oZYHkJ83ZlZDzOf1rcPOpNUeZYZTkW+tQ/sLpw/2nR+glT9+OvKUjLp9L3kuTAdoL6WGcFi0ID
YXkHCtVIzozhZ6B89PAYzq/EU7RpvN/m2ojmYlovpenJJZhm8xxs3/R/AP1mpjGiEgAA
E-O
--[
PREV |
HOME |
NEXT ]--