The last four bytes of every section header is a 32-bit field that decides how the linker treats that section: is it code, is it writable, must it be executable, can it be thrown away. It is also the field most often mis-decoded, because part of it is a set of independent flags and part of it is a four-bit number that only looks like a flag.
Getting that wrong does not produce a crash. It produces a tool that reports a section as having seven different alignments at once, and an alignment that appears in no list of valid values.
Split the 32 bits into three regions, because they obey three different rules:
The mental model that keeps this straight: a value of 5 in a four-bit field is the binary number 0101, not "flag 0 and flag 2". Testing the field against fifteen named constants is the correct approach; testing each of those constants as a bitmask is not.
Take .text's real value, 0x60500020, and take it apart:
0x60500020 = 0110 0000 0101 0000 0000 0000 0010 0000
| | | |
| | | +-- bit 5: CNT_CODE
| | +-- bits 20-23: 0101 = 5 = ALIGN_16BYTES
| +-- bit 30: MEM_READ
+-- bit 29: MEM_EXECUTE| Mask | Name | Test |
|---|---|---|
| 0x00000020 | IMAGE_SCN_CNT_CODE | flag — test with & |
| 0x00000040 | IMAGE_SCN_CNT_INITIALIZED_DATA | flag |
| 0x00000080 | IMAGE_SCN_CNT_UNINITIALIZED_DATA | flag |
| 0x00001000 | IMAGE_SCN_LNK_COMDAT | flag |
| 0x00000800 | IMAGE_SCN_LNK_REMOVE | flag |
| 0x02000000 | IMAGE_SCN_MEM_DISCARDABLE | flag |
| 0x20000000 | IMAGE_SCN_MEM_EXECUTE | flag |
| 0x40000000 | IMAGE_SCN_MEM_READ | flag |
| 0x80000000 | IMAGE_SCN_MEM_WRITE | flag |
| 0x00F00000 | IMAGE_SCN_ALIGN_* | field — shift, then compare |
And here is the trap, stated as the exact bug it produces. 0x60500020 has the value 5 in the alignment field. A decoder written as a list of bitmask tests does this:
Seven alignments reported, where the file has one. The correct decode is a shift and a compare against a lookup:
This was not a hypothetical. It is the first thing that went wrong when the independent parser for this course was written, and the reason the parser now masks the field explicitly rather than relying on its own flag list.
Why is a field, and not a flag? Because alignment values are ordinal, not independent. 1-byte, 2-byte, 4-byte, 8-byte and 16-byte are increasing amounts of the same thing, so the format stores "how many steps up the ladder" rather than five separate yes/no bits. Four bits leaves room for 16 steps, which covers 1 byte up to 8 KB.
| Section | Value | align | Flags llvm-readobj prints |
|---|---|---|---|
.text | 0x60500020 | 5 | CNT_CODE, ALIGN_16BYTES, MEM_EXECUTE, MEM_READ |
.data | 0xC0500040 | 5 | CNT_INITIALIZED_DATA, ALIGN_16BYTES, MEM_READ, MEM_WRITE |
.bss | 0xC0300080 | 3 | CNT_UNINITIALIZED_DATA, ALIGN_4BYTES, MEM_READ, MEM_WRITE |
.xdata | 0x40300040 | 3 | CNT_INITIALIZED_DATA, ALIGN_4BYTES, MEM_READ |
.rdata | 0x40100040 | 1 | CNT_INITIALIZED_DATA, ALIGN_1BYTES, MEM_READ |
.rdata (COMDAT) | 0x40101040 | 1 | CNT_INITIALIZED_DATA, LNK_COMDAT, ALIGN_1BYTES, MEM_READ |
.debug$S | 0x42300040 | 3 | CNT_INITIALIZED_DATA, ALIGN_4BYTES, MEM_DISCARDABLE, MEM_READ |
.pdata | 0x40300040 | 3 | CNT_INITIALIZED_DATA, ALIGN_4BYTES, MEM_READ |
.llvm_addrsig | 0x00100800 | 1 | LNK_REMOVE, ALIGN_1BYTES |
Read that table as the linker's instruction sheet. Code is executable and read-only. .data is writable. .bss is writable and uninitialised. .debug$S is discardable, which means the linker throws it away unless debug information is requested — the mechanism behind a debug build's size. .llvm_addrsig is marked remove because it is metadata for the linker, not content for the program.
Here are the characteristic fields as they actually sit in the file, at the last four bytes of each section header:
.text hdr 0x14, chars at 0x38: 20 00 50 60 -> 0x60500020 align 5 .data hdr 0x3c, chars at 0x60: 40 00 50 c0 -> 0xc0500040 align 5 .bss hdr 0x64, chars at 0x88: 80 00 30 c0 -> 0xc0300080 align 3 .xdata hdr 0x8c, chars at 0xb0: 40 00 30 40 -> 0x40300040 align 3 .rdata hdr 0xb4, chars at 0xd8: 40 00 10 40 -> 0x40100040 align 1 .rdata hdr 0xdc, chars at 0x100: 40 00 10 40 -> 0x40101040 align 1 (COMDAT) .debug$S hdr 0x104, chars 0x128: 40 00 30 42 -> 0x42300040 align 3 (discardable)
Compare the third and sixth lines. Both are .rdata, and they differ by exactly one bit:
plain .rdata: 40 00 10 40 0x40100040
COMDAT .rdata: 40 00 10 40 0x40101040
^^^^^^^
0x1000 = IMAGE_SCN_LNK_COMDATNothing about the two sections' contents differs in the header. They are both read-only initialised data with 1-byte alignment. The LNK_COMDAT bit is the linker's instruction that this section is a candidate for deduplication, and the detail of how to deduplicate it lives in the symbol table, in the aux record's Selection and CheckSum fields. That is the subject of the COMDAT concept.
One more check that the decode is right, and it is the same kind of self-consistency test used for the file header. The .text section's first instruction is a push rax (50) at file offset 0x17C, and .text is marked MEM_EXECUTE | MEM_READ with no MEM_WRITE. A writable code section would be a red flag to every platform's security model; here the flags and the bytes agree, which is what you would expect from a compiler that got it right.
$ clang --target=x86_64-pc-windows-msvc -c sample.c -o sample_msvc.obj
$ llvm-readobj --sections sample_msvc.obj
$ xxd -s 0x38 -l 4 sample_msvc.objThen reproduce the bug deliberately, because seeing it happen is the point. Decode 0x60500020 both ways in any language:
// WRONG: treats alignment as a set of flags
for name, mask in [("1B",0x00100000),("2B",0x00200000),("4B",0x00300000),
("8B",0x00400000),("16B",0x00500000),("32B",0x00600000)]:
if 0x60500020 & mask == mask: print("reports", name)
// RIGHT: treats it as a field
print("align =", (0x60500020 >> 20) & 0xF, "= 16 bytes")What to look for: the wrong version prints several alignments for one section, the right version prints one. Then check llvm-readobj's output against the right version — it prints exactly one alignment per section, and it agrees.
Worth noticing: llvm-readobj prints the flags and the raw value in parentheses, e.g. Characteristics [ (0x60500020). Reading both the number and the names is the fastest way to catch a decode bug, because a value and its flag list that do not correspond is immediately visible.
Without looking back: a section header has Characteristics = 0x40300040. Which four flags does it carry, and what is its alignment?
You are auditing a build and want to flag any section that will end up writable and executable in the linked image, because that combination is what modern exploit mitigations look for. You have written the test as a pair of bitmask checks. What is wrong with your approach, and what does the audit actually have to do?
The general habit: know which of a field’s bits are flags and which are numbers, and use the right test for each. But also know that decoding a file correctly and interpreting it correctly are separate skills — a perfectly decoded object header still does not tell you what the final executable will look like.
These same permission bits appear in the PE section headers, where they do describe the running image — see PE common sections and PE security flags. The ELF course has the same idea under a different name, with SHF_WRITE and SHF_EXECINSTR in sh_flags. Three formats, one question: may this page be written, and may it be executed?
That closes Module 1. You have the container: a 20-byte header that says what kind of file this is, 40-byte section headers that say where the pieces are, and a characteristics field that says what the linker should do with them.
Module 2 opens the tables that make an object relocatable — starting with the simplest one, the string pool that every other name is resolved through.
Next: The String Table — eight bytes, two meanings, and one place where the format quietly runs out.