Safe Penetration Testing Labs · intermediate · ~15 min
**What you will learn** - Read the fixed 24-byte layout of an `Elf64_Sym` entry and locate the `st_info` field by byte offset. - Use bitwise operators to split `st_info` into a *binding* (high nibble) and a *type* (low nibble). - Walk a raw `.symtab` byte buffer safely with a fixed stride, without casting to a struct pointer (avoiding alignment undefined behavior). - Validate untrusted input: reject buffers whose length is not a multiple of the entry size, and handle NULL/empty buffers correctly. - Interpret the global-symbol count as a triage signal that separates a stripped binary from an un-stripped one during authorized malware/reverse-engineering analysis.
Security objective. In an authorized reverse-engineering lab, the asset you protect is your analysis workstation and the correctness of your triage report. The threat is a hostile binary (an unknown sample) that hands you a deliberately malformed symbol table to crash your parser, read out of bounds, or make you report the wrong thing. This lesson teaches you to read one small slice of an ELF file — the symbol table (.symtab) — and count how many symbols have global binding, while treating every byte in that file as attacker-controlled.
An ELF file (Executable and Linkable Format) is the standard binary format on Linux: executables, shared libraries (.so), and object files (.o) are all ELF. Its symbol table lists the named functions and variables the program declares. Reverse-engineering tools — nm, objdump, readelf, Ghidra, radare2 — all start by reading this table. The global-symbol count is a one-glance signal: a stripped binary (symbols removed to slow analysis) has very few, while an un-stripped build exposes many.
This builds directly on your prerequisites. From pointers you already know how to hold a const uint8_t * and index into a buffer. From bitwise-operators you know >> (shift right) and & (mask), which we use to pull the binding out of a packed byte. From structs you know the idea of a fixed field layout — an Elf64_Sym is exactly that — but here you will read fields by explicit byte offset instead of casting, because untrusted, possibly-misaligned bytes make a struct cast unsafe.
In real, authorized security work you constantly receive files you did not create and cannot trust. A malware analyst pulls a suspicious sample from a sandbox; an incident responder recovers a binary from a compromised host; a firmware researcher extracts an executable from a device image. The very first questions are triage questions: Is this stripped? How much symbol information survived? Is the file even well-formed, or is it truncated/corrupt?
A global-symbol count answers the first two in one number, and a strict length check answers the third. Writing the parser yourself — instead of shelling out to readelf — matters because (1) you learn exactly what the tools do, so you can spot when a crafted file fools them; (2) you can run at scale over thousands of samples without spawning a process each time; and (3) building the habit of validate-before-you-trust on a tiny, well-defined structure is exactly the discipline that prevents parser bugs, which are one of the most common sources of memory-safety vulnerabilities in real security tooling.
.symtab)Definition. .symtab is a section inside an ELF file that lists named symbols — functions and data objects — as an array of fixed-size records.
Plain explanation. Think of it as a spreadsheet: one row per symbol, every row the same width. On a 64-bit ELF, each row (Elf64_Sym) is exactly 24 bytes. Because the width is fixed, you can jump to symbol i at byte offset i * 24 without parsing anything before it.
How it works. You do not need a full ELF parser to answer a counting question. Given just the raw .symtab bytes and their length, you can stride through them 24 at a time.
When / when not. Use raw walking when you already have the section bytes and want a fast, self-contained answer. Do not use it to resolve symbol names — the names live in a separate .strtab section, referenced by st_name. That is a different lesson.
Pitfall. Assuming the buffer length is always a clean multiple of 24. Attacker-supplied or truncated files break that assumption; a leftover partial entry means the table is corrupt.
Elf64_Sym layoutEach 24-byte entry:
offset size field meaning
0 4 st_name index into .strtab (the name)
4 1 st_info high nibble = binding, low nibble = type
5 1 st_other visibility
6 2 st_shndx section index
8 8 st_value address / value
16 8 st_size size in bytes
The one byte we care about is st_info at offset 4.
st_info byteDefinition. st_info packs two 4-bit fields into one byte. The binding (visibility/linkage) is the high nibble; the type (what kind of symbol) is the low nibble.
How it works (bitwise).
binding = st_info >> 4; // high nibble: LOCAL=0, GLOBAL=1, WEAK=2
type = st_info & 0x0F; // low nibble: NOTYPE=0, OBJECT=1, FUNC=2, ...
We count an entry when binding == 1 (GLOBAL).
When / when not. Shift-right for the high nibble; mask for the low nibble. Do not mix them up — a common bug is to & 0xF when you meant >> 4.
Pitfall. Reading st_info as a signed char. Use unsigned char / uint8_t so the shift is well-defined and you never sign-extend the top bit.
Definition. The trust boundary is the line between data you control and data you do not. Here it sits at the function parameter: the moment untrusted file bytes enter count_global_symbols, every assumption must be checked, not assumed.
How it works. Three guards: reject NULL with a positive length; accept NULL with zero length as an empty table; reject any length not divisible by 24.
THREAT MODEL — authorized ELF triage
Untrusted input (an unknown sample)
| crafted / truncated .symtab bytes
v
+--------------------- TRUST BOUNDARY ---------------------+
| count_global_symbols(buf, n) |
| ASSET: parser integrity + correct triage result |
| ENTRY POINT: the (buf, n) pair |
| GUARDS: |
| - n % 24 != 0 -> reject (-1) [corrupt] |
| - buf == NULL, n > 0 -> reject (-1) [invalid] |
| - buf == NULL, n = 0 -> accept (0) [empty] |
| READS ONLY: buf[i*24 + 4] (never past n) |
+----------------------------------------------------------+
|
v
Trusted result: a count you can put in a report
Knowledge check.
n % 24 != 0 check? (Your parser's memory safety and the correctness of the reported count — a partial trailing entry would otherwise make you read fields that do not exist.)count_global_symbols(buf, n) call — everything reachable through buf is attacker-controlled.)buf[i*24 + 4] on a truncated final entry.)The whole technique is offset arithmetic plus two bitwise ops. Nothing exotic.
#include <stdint.h> // uint8_t, fixed-width types
#include <stddef.h> // size_t
#define ELF64_SYM_SIZE 24u // one Elf64_Sym on disk
#define ST_INFO_OFFSET 4u // st_info sits at byte 4
#define STB_GLOBAL 1u // binding value for GLOBAL
/* For entry i, the st_info byte is: */
unsigned char st_info = buf[i * ELF64_SYM_SIZE + ST_INFO_OFFSET];
/* Split the packed byte: */
unsigned binding = (unsigned)st_info >> 4; // high nibble
unsigned type = (unsigned)st_info & 0x0Fu; // low nibble
Key points:
uint8_t / unsigned char for raw bytes so shifts are defined and never sign-extend.buf[i*24 + 4]; do not cast buf to Elf64_Sym * (alignment risk — see the memory-safety section).n / 24 after you have confirmed n % 24 == 0.Every reverse-engineering tool starts by reading the symbol table out of an ELF binary. This includes nm, objdump, readelf, Ghidra, and radare2.
An ELF file (Executable and Linkable Format) is the standard binary format on Linux. Its symbol table (.symtab) lists the named functions and variables in the program.
The on-disk layout is fixed and predictable:
st_info field.You do not need a full ELF parser to make use of .symtab. To count entries with a given property, you only need to walk the raw bytes.
Each 24-byte symbol entry has this layout:
offset size field
0 4 st_name
4 1 st_info <- top 4 bits = binding, low 4 bits = type
5 1 st_other
6 2 st_shndx
8 8 st_value
16 8 st_size
The binding tells you the symbol's visibility:
0 LOCAL1 GLOBAL (the ones we count)2 WEAKThe binding is the high nibble of st_info, so:
binding = st_info >> 4;
Implement:
int count_global_symbols(const uint8_t *buf, size_t n);
Here buf is the raw .symtab bytes and n is its length.
Return the number of GLOBAL-binding entries. Handle these cases:
n is not a multiple of 24 -> return -1. An incomplete entry means a corrupt symbol table.buf is NULL and n == 0 -> return 0. An empty table is fine.buf is NULL and n > 0 -> return -1.n must be a multiple of 24.struct * and walking with it. This is alignment-unsafe on some hosts. Just index the bytes directly: buf[i*24 + 4]..strtab here..rela.dyn is a separate module.Below: an intentionally weak parser, the hardened version, and a self-test that proves the fix rejects bad input and accepts good input.
/* insecure_count.c — DO NOT USE IN PRODUCTION.
* Two bugs: (a) casts to a struct pointer (alignment UB) and
* (b) never checks that n is a whole number of entries, so a
* truncated buffer reads past the end. */
#include <stdint.h>
#include <stddef.h>
struct Elf64_Sym { /* mirrors the on-disk layout */
uint32_t st_name;
uint8_t st_info;
uint8_t st_other;
uint16_t st_shndx;
uint64_t st_value;
uint64_t st_size;
};
int count_global_bad(const uint8_t *buf, size_t n) {
const struct Elf64_Sym *sym = (const struct Elf64_Sym *)buf; /* alignment UB */
size_t count = 0;
for (size_t i = 0; i < n / 24; i++) { /* ignores a partial trailing entry ... */
if ((sym[i].st_info >> 4) == 1) count++; /* ... and may read fields OOB */
}
return (int)count; /* no NULL check, no size validation */
}
Why it is dangerous: casting an arbitrary buf to struct Elf64_Sym * is undefined behavior if buf is not suitably aligned, and struct padding is not guaranteed to match the 24-byte on-disk layout. With no n % 24 check, a crafted 23-byte-tail buffer makes the loop math and struct reads inconsistent with reality.
/* secure_count.c */
#include <stdint.h>
#include <stddef.h>
#define ELF64_SYM_SIZE 24u
#define ST_INFO_OFFSET 4u
#define STB_GLOBAL 1u
/* Returns the number of GLOBAL-binding symbols, or -1 on invalid input. */
int count_global_symbols(const uint8_t *buf, size_t n) {
if (buf == NULL) {
return (n == 0) ? 0 : -1; /* empty table ok; NULL with data is invalid */
}
if (n % ELF64_SYM_SIZE != 0) {
return -1; /* partial trailing entry => corrupt table */
}
size_t entries = n / ELF64_SYM_SIZE;
int count = 0;
for (size_t i = 0; i < entries; i++) {
unsigned char st_info = buf[i * ELF64_SYM_SIZE + ST_INFO_OFFSET];
unsigned binding = (unsigned)st_info >> 4; /* high nibble */
if (binding == STB_GLOBAL) {
count++;
}
}
return count;
}
/* test_count.c — compile: cc -std=c11 -Wall -Wextra -fsanitize=address,undefined \
* test_count.c secure_count.c -o test_count && ./test_count */
#include <stdint.h>
#include <stddef.h>
#include <stdio.h>
#include <assert.h>
#define ST_INFO_OFFSET 4u /* keep the test self-contained */
int count_global_symbols(const uint8_t *buf, size_t n);
/* Build one 24-byte entry with a given st_info at offset 4. */
static void make_entry(uint8_t *dst, uint8_t st_info) {
for (int i = 0; i < 24; i++) dst[i] = 0;
dst[ST_INFO_OFFSET] = st_info;
}
int main(void) {
/* GOOD input: 3 entries — GLOBAL, LOCAL, GLOBAL => expect 2 */
uint8_t good[72];
make_entry(good + 0, 0x10); /* binding=1 GLOBAL, type=0 */
make_entry(good + 24, 0x02); /* binding=0 LOCAL, type=2 FUNC */
make_entry(good + 48, 0x12); /* binding=1 GLOBAL, type=2 FUNC */
assert(count_global_symbols(good, sizeof good) == 2);
/* ACCEPT edge: empty table */
assert(count_global_symbols(NULL, 0) == 0);
assert(count_global_symbols(good, 0) == 0);
/* REJECT: length not a multiple of 24 (truncated final entry) */
assert(count_global_symbols(good, 72 - 1) == -1);
assert(count_global_symbols(good, 23) == -1);
/* REJECT: NULL with positive length */
assert(count_global_symbols(NULL, 24) == -1);
puts("all checks passed");
return 0;
}
Expected output: all checks passed. Note the test #defines its own ST_INFO_OFFSET 4u so it stays self-contained; you could equally write dst[4] directly. Running under -fsanitize=address,undefined is the point: if any guard were missing, the truncated-length or NULL cases would trip the sanitizer instead of silently returning garbage.
Walkthrough of count_global_symbols on the good buffer (3 entries, n = 72):
if (buf == NULL) — buf is a real 72-byte array, so this is false; we skip the NULL handling.if (n % 24 != 0) — 72 % 24 == 0, so the table is a whole number of entries; we continue.entries = n / 24 — 72 / 24 = 3. We will loop exactly three times and never touch a byte past index 71.count = 0 — running tally starts empty.| i | byte read: buf[i*24 + 4] |
st_info (hex) |
binding = st_info >> 4 |
GLOBAL? | count |
|---|---|---|---|---|---|
| 0 | buf[4] |
0x10 |
0x10 >> 4 = 1 |
yes | 1 |
| 1 | buf[28] |
0x02 |
0x02 >> 4 = 0 |
no | 1 |
| 2 | buf[52] |
0x12 |
0x12 >> 4 = 1 |
yes | 2 |
return count; returns 2.Why the shift gives the binding: 0x12 is 0001 0010 in binary. >> 4 slides the top nibble down: 0000 0001 = 1 = GLOBAL. The low nibble (0010 = 2 = FUNC) is discarded — that is the type, which this function ignores. For the rejection cases, step 2's n % 24 != 0 returns -1 before any indexing happens, so a truncated buffer can never cause an out-of-bounds read.
1. Mixing up binding and type.
if ((st_info & 0x0F) == 1) count++; to find GLOBAL.& 0x0F extracts the low nibble (the type). GLOBAL binding lives in the high nibble.if ((st_info >> 4) == 1) count++;binding and type explicitly; write a quick assert that 0x12 yields binding 1 and type 2.2. Skipping the size validation.
for (i = 0; i < n/24; i++) with no n % 24 check.-1 when n % 24 != 0 before looping.n = 23 and n = 25; both must return -1.3. Casting the buffer to Elf64_Sym *.
const Elf64_Sym *s = (const Elf64_Sym *)buf;buf is misaligned, and compiler padding may not match the 24-byte on-disk layout.buf[i*24 + 4].-fsanitize=undefined; alignment violations are reported.4. Reading st_info as signed char.
char info = buf[...]; binding = info >> 4;0x80 becomes negative; shifting a negative value right is implementation-defined and can sign-extend.unsigned char / uint8_t and cast to unsigned before shifting.uint8_t buffers everywhere; enable -Wsign-conversion.5. Confusing NULL-with-zero and NULL-with-data.
if (buf == NULL) return -1; for all NULL cases.(NULL, 0) and should return 0, not an error.return (n == 0) ? 0 : -1;Common errors and how to chase them.
binding == 1 against the wrong nibble, or your test data has GLOBAL in every entry. Print each st_info in hex and the derived binding; confirm they differ across entries.n. Re-check the guard order: validate n % 24 and NULL before the loop, and confirm the loop bound is n / 24, not n.-fsanitize=address,undefined.-1 returned for a file you believe is valid. The section you passed may include padding or may not be the real .symtab. Confirm the byte length with readelf -S <file> (look at the .symtab size) and check it is a multiple of 24 before blaming the parser.Questions to ask when it fails.
n exactly the .symtab size, or did I accidentally pass the whole file / a padded slice?uint8_t, and am I casting to unsigned before the shift?-1/0), never trip a sanitizer.Cross-check with a trusted tool (lab-safe): readelf -s ./yourbinary | grep -c GLOBAL gives an independent count you can compare against. If your number and readelf's disagree, decide which is right by inspecting a few entries with readelf -s.
Memory / UB safety for this parser
n. Every access is buf[i*24 + 4] with i < n/24, and only after n % 24 == 0 is confirmed — so the largest index touched is (n/24 - 1)*24 + 4 = n - 20, comfortably inside the buffer.uint8_t / unsigned char keep the shift well-defined.const buffer — so there is no leak or double-free surface.Security & safety — detection and logging (for the tool that uses this parser)
When you wrap this in a triage pipeline over untrusted samples, log enough to investigate later without leaking sensitive data:
.symtab, its byte length); the security decision and result (accepted, entries=42, global=7 or rejected: length 4097 not a multiple of 24); and the analyst/host that ran it.-1 rejections (someone feeding truncated/corrupt tables to probe your parser), symbol counts wildly larger than the file size could hold, or the same hash repeatedly triggering a crash path.-1 can also come from your own slicing bug rather than a hostile file; verify the section length before concluding the sample is corrupt.Authorized use case. A malware-analysis team receives thousands of ELF samples a day into an isolated sandbox. Before the expensive steps (disassembly, dynamic execution in a VM), a fast triage pass counts global symbols per sample: near-zero flags a stripped/packed binary for deeper attention, while a rich symbol table lets analysts jump straight to named functions. Doing this in-process — no readelf fork per file — keeps the pipeline fast at scale.
Professional best-practice habits.
| Habit | Beginner application | Advanced application |
|---|---|---|
| Validate untrusted input | Check n % 24 and NULL before looping |
Also validate the section is truly .symtab via the section header, and bound the entry count against file size |
| Least privilege | Analyze files as an unprivileged user | Run the whole pipeline in a container/VM with no network and read-only mounts |
| Secure defaults | Return -1 on anything unexpected |
Fail closed: quarantine the sample and alert, rather than "best-effort" parsing |
| Logging | Record accept/reject + count | Correlate by file hash, track rejection-rate trends, alert on crash-path repeats |
| Error handling | Distinguish empty (0) from invalid (-1) |
Surface why it was rejected so analysts can tell corruption from an attack |
Authorization reminder. Only parse and (especially) run samples on systems you own or are explicitly authorized to analyze. Keep unknown binaries in an isolated lab (container, disposable VM, or an air-gapped host), never on your daily machine.
All tasks are lab-only: work on files you created or are authorized to analyze, on an isolated machine.
Beginner 1 — Extract the binding.
int symbol_binding(unsigned char info) returning the high nibble (info >> 4).0x12 -> 1, 0x21 -> 2, 0x00 -> 0.Beginner 2 — Extract the type.
int symbol_type(unsigned char info) returning the low nibble (info & 0x0F).0x12 -> 2, 0x2A -> 10, 0xF0 -> 0.uint8_t.Intermediate 1 — Count by any binding.
int count_symbols_with_binding(const uint8_t *buf, size_t n, unsigned want) — count entries whose binding equals want.NULL+n, n % 24); return -1 on invalid input.want=1, return 2.Intermediate 2 — Type histogram.
int counts[16] with how many symbols have each type nibble (0..15); return -1 on invalid input, else 0.st_info & 0x0F.Challenge — Robust triage summary with a defensive conclusion.
int symtab_summary(const uint8_t *buf, size_t n, int *out_total, int *out_global) that validates the buffer, sets *out_total and *out_global, and returns 0 on success or -1 on any invalid input (leaving the outputs untouched on failure).n/multiple-of-24); no reads past n; no struct cast. Build a small test that feeds (a) a good multi-entry buffer, (b) a truncated buffer, (c) (NULL, 0), and (d) (NULL, 24), asserting the expected return and outputs. Compile with -std=c11 -Wall -Wextra -fsanitize=address,undefined.-1, callers must not read the outputs.n % 24 guard, watch the sanitizer flag the truncated case, then restore the guard and confirm the test passes again. That before/after is your proof the mitigation works.out_* only after all guards pass. Concepts: pointers, validation, defensive programming, mitigation verification.Main concepts. An ELF64 symbol table is an array of fixed 24-byte Elf64_Sym records. The st_info byte at offset 4 packs binding (high nibble) and type (low nibble); GLOBAL binding is value 1. To count global symbols you stride the raw buffer 24 bytes at a time and test buf[i*24 + 4] >> 4 == 1.
Key syntax/commands.
binding = buf[i*24 + 4] >> 4; (high nibble)type = buf[i*24 + 4] & 0x0F; (low nibble)readelf -s <file> | grep -c GLOBAL.cc -std=c11 -Wall -Wextra -fsanitize=address,undefined.Common mistakes. Confusing binding (>> 4) with type (& 0x0F); skipping the n % 24 check; casting buf to a struct pointer (alignment UB); reading bytes as signed char; treating (NULL, 0) as an error.
What to remember. Validate before you trust: reject n % 24 != 0 and NULL-with-data, accept the empty table, and only ever read within n. The global-symbol count is a triage signal, not a verdict — a stripped binary is normal, not proof of malice. Do this only on files and machines you are authorized to analyze, in an isolated lab, and log the decision (by file hash) without ever logging the raw sample contents.