Foundationপ্রথম নীতি থেকে
LEVEL 3লেসন ১১/১২মাঝারি৫০ মিনিট

ELF Format — একটা বাইনারি ফাইলের ভেতরের নকশা

ELF Format

ELF একটাই container format — object file, executable, shared library সবই — magic bytes দিয়ে শুরু, headers দিয়ে linker/loader-কে গাইড করে; section গুলো build-time granularity, segment গুলো runtime mapping, আর অনেক section মিলে একটা segment হয়।

এই লেসন শেষে আপনি পারবেন

  • ELF কীভাবে object file, executable, ও shared library — তিনটারই জন্য একই container format, শুধু ELF header-এর e_type ফিল্ড দিয়ে আলাদা করা — তা ব্যাখ্যা করতে পারবেন
  • ELF header-এর মূল ফিল্ড (magic bytes, e_type, e_machine, e_entry, e_shoff, e_phoff) readelf -h আউটপুট থেকে পড়ে অর্থ বলতে পারবেন
  • Section আর segment-এর পার্থক্য স্পষ্টভাবে বলতে পারবেন — section লিংকার/টুলের জন্য (build-time), segment লোডারের জন্য (runtime), আর কীভাবে অনেক section একটা segment-এ মেলে
  • .text, .data, .bss, .rodata, .symtab, .dynsym — প্রতিটার উদ্দেশ্য বলতে পারবেন, বিশেষত .bss ফাইলে সংরক্ষিত না থাকার কারণ ও সুবিধা
  • readelf -h, readelf -S, readelf -l, ও objdump -d আউটপুট পড়ে একটা বাস্তব বাইনারির গঠন নিজে বর্ণনা করতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

Assembler-linker-loader লেসনে আমরা পুরো pipeline দেখেছিলাম:

source.c → [compiler] → source.s → [assembler] → source.o
    → [linker] → a.out → [loader] → running process

একটা প্রশ্ন তখন এড়িয়ে গিয়েছিলাম — .o আর a.out ফাইলগুলো আসলে কী? এগুলো কি টেক্সট? বাইনারি নিশ্চয়ই, কিন্তু কোন গঠনে? Assembler .o-তে কী লেখে যাতে linker সেটা বুঝতে পারে? Linker a.out-এ কী লেখে যাতে loader জানে কোথায় কোড, কোথায় ডেটা, কোথায় শুরু করতে হবে?

$ file probe.o
probe.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

$ file libgreet.so
libgreet.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, ...

$ file a.out
a.out: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, ...

তিনটাই — object file, shared library, executable — ELF। একই শব্দ, তিনবার। এটা কাকতাল না — Linux-এ (আর বেশিরভাগ Unix-এ) এই তিনটা জিনিসই আক্ষরিক অর্থে একই container format ব্যবহার করে। পার্থক্য শুধু ভেতরের একটা ফিল্ডের মানে, আর কোন অংশগুলো উপস্থিত তাতে।

গত লেসনে আমরা readelf -d, readelf -r, objdump -d দিয়ে GOT আর PLT দেখেছি — সেই সব তথ্যই এই একই ফাইল-ফরম্যাটের ভেতরে সংরক্ষিত। এই লেসনে আমরা পুরো ফরম্যাটটা খুলে দেখব — ELF: Executable and Linkable Format

মূল ধারণা

Magic bytes — প্রথম চার byte

প্রতিটা ELF ফাইল ঠিক এই চার byte দিয়ে শুরু হয়:

7f 45 4c 46

0x7f তারপর ASCII-তে E, L, F"\x7fELF"। এটাই OS-কে (আর file কমান্ডকে) বলে দেয় “এই ফাইলটা ELF, তোমার parser দিয়ে পড়ো”। execve() সিস্টেম কল প্রথমেই এই চার byte চেক করে — না মিললে ENOEXEC error।

$ xxd -l 16 /bin/ls
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............

Magic-এর পরের byte গুলোও তথ্যবহুল — 02 মানে 64-bit (01 হলে 32-bit), 01 মানে little-endian (02 হলে big-endian)।

ELF header — পুরো ফাইলের সূচিপত্র

Magic bytes-এর পরই আসে ELF header — একটা fixed-size struct যা বাকি পুরো ফাইলটা কীভাবে পড়তে হবে তার নির্দেশিকা।

$ readelf -h /bin/ls
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              DYN (Position-Independent Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x6b60
  Start of program headers:          64 (bytes into file)
  Start of section headers:          142208 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         13
  Size of section headers:           64 (bytes)
  Number of section headers:         31
  Section header string table index: 30

প্রতিটা লাইন গুরুত্বপূর্ণ:

ফিল্ডঅর্থ
TypeET_REL / ET_EXEC / ET_DYN / ET_CORE — এই ফাইলটা কী (নিচে)
Machineকোন CPU architecture-এর জন্য (x86-64, AArch64, …) — ভুল আর্কিটেকচারে চালানোর চেষ্টা এখানেই ধরা পড়ে
Entry point addressকোথা থেকে execution শুরু হবে (loader এই ঠিকানায় jump করে)
Start of program headerssegment তালিকা ফাইলের কোথায় শুরু (loader পড়ে)
Start of section headerssection তালিকা ফাইলের কোথায় শুরু (linker/tool পড়ে)
Number of program/section headersকতগুলো segment/section আছে

e_type — একই format, তিন ভূমিকা

Type ফিল্ডটাই সেই এক জায়গা যেখানে “object file, executable, নাকি shared library” সিদ্ধান্ত হয়:

e_typeসংক্ষেপেমানে
ET_RELRelocatable.o ফাইল — লিংক করা হয়নি, ঠিকানা এখনো ঠিক হয়নি
ET_EXECExecutableপুরনো-স্টাইল non-PIE executable, fixed load address
ET_DYNShared objectshared library অথবা PIE executable (গত লেসনে PT_INTERP দিয়ে আলাদা করেছিলাম)
ET_CORECore dumpক্র্যাশ হওয়া প্রসেসের memory snapshot (নিচে realworld-এ)
$ readelf -h probe.o | grep Type
  Type:                              REL (Relocatable file)

$ readelf -h libgreet.so | grep Type
  Type:                              DYN (Shared object file)

$ readelf -h ./a.out | grep Type
  Type:                              DYN (Position-Independent Executable file)

ভেতরে কী ঘটছে

Section বনাম Segment — সবচেয়ে সাধারণ বিভ্রান্তি

ELF-এ দুইটা সমান্তরাল “মানচিত্র” আছে একই ফাইলের content বর্ণনা করার জন্য — আর তারা ভিন্ন পাঠকের জন্য, ভিন্ন উদ্দেশ্যে বানানো।

Sectionlinker আর analysis টুলের জন্য। সূক্ষ্ম-দানার, নাম-করা অংশ: .text, .data, .symtab, .rela.plt, ইত্যাদি। Build-time-এ linker section-গুলো নিয়ে কাজ করে — একাধিক .o-র .text section একসাথে জোড়া লাগিয়ে চূড়ান্ত .text বানায়, symbol resolve করে, relocation প্রয়োগ করে। readelf -S, objdump -h, nm — এই সব টুল section-ভিত্তিক তথ্য দেখায়।

Segment (program header-এ বর্ণিত) — loader/kernel-এর জন্য। মোটা-দানার, নামহীন (শুধু type থাকে) memory region: “এই byte range-টা পড়ো-চালাও (R-X) হিসেবে map করো”, “ঐটা পড়ো-লেখো (RW) হিসেবে”। Runtime-এ execve() শুধু segment তালিকা পড়ে — section header পুরোপুরি উপেক্ষা করে (আসলে একটা stripped executable-এ section header থাকতেই পারে না, তবু চলে — নিচে দেখুন)।

Sections (readelf -S দেখায়):
┌────────┬────────┬────────┬────────┬─────────┬─────────┐
│ .text  │.rodata │ .data  │  .bss  │ .symtab │ .strtab │
└────────┴────────┴────────┴────────┴─────────┴─────────┘

Segments (readelf -l দেখায়) — একই bytes-এর উপর superimposed:
┌─────────────────────────┬───────────────────┬──────────────────────┐
│ LOAD #1 (R-E)            │ LOAD #2 (RW)       │ (mapped না — build   │
│ .text + .rodata          │ .data + .bss       │  metadata মাত্র)      │
└─────────────────────────┴───────────────────┴──────────────────────┘
                                                  .symtab, .strtab
একই ফাইল, দুইটা স্বাধীন view — section (fine-grained, build-time) আর segment (coarse-grained, runtime)।

সম্পর্কটা many-to-one — অনেক section একটা segment-এ গোষ্ঠীবদ্ধ হয়। কেন গোষ্ঠীবদ্ধ করা দরকার: memory protection hardware page (সাধারণত 4 KiB) granularity-তে কাজ করে, section granularity-তে না। .text (কোড, execute-able হওয়া উচিত) আর .rodata (string literal, read-only হওয়া উচিত কিন্তু execute-able না হওয়া উচিত) — দুটোরই permission “read + maybe-execute, write না” — তাই একসাথে একটা page-aligned RX (বা R) segment-এ গোষ্ঠীবদ্ধ করা efficient। প্রতিটা আলাদা section-এর জন্য আলাদা page mapping করলে শত শত mapping লাগত, memory আর TLB-এর অপচয়।

readelf -S — Section table

$ readelf -S probe.o
There are 13 section headers, starting at offset 0x2a8:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000000000  00000040
       0000000000000018  0000000000000000  AX       0     0     1
  [ 2] .data             PROGBITS         0000000000000000  00000058
       0000000000000004  0000000000000000  WA       0     0     4
  [ 3] .bss              NOBITS           0000000000000000  0000005c
       0000000000000004  0000000000000000  WA       0     0     4
  [ 4] .rodata           PROGBITS         0000000000000000  0000005c
       0000000000000004  0000000000000000   A       0     0     1
  [ 5] .rela.text        RELA             0000000000000000  000001a0
       0000000000000018  0000000000000018   I       9    1     8
  [ 6] .symtab           SYMTAB           0000000000000000  00000110
       00000000000000a8  0000000000000018          10     5     8
  [ 7] .strtab           STRTAB           0000000000000000  000001b8
       0000000000000013  0000000000000000           0     0     1
  [ 8] .shstrtab         STRTAB           0000000000000000  000002dc
       0000000000000053  0000000000000000           0     0     1

লক্ষ্য করুন: .bss-এর Type NOBITS, বাকি সব PROGBITS। এইটাই এই লেসনের একটা গুরুত্বপূর্ণ পয়েন্ট।

readelf -l — Program header table (Segments)

$ readelf -l ./a.out

Elf file type is DYN (Position-Independent Executable file)
Entry point 0x1139
There are 13 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000000040 0x0000000000000040
                 0x00000000000002d8 0x00000000000002d8  R      0x8
  INTERP         0x0000000000000318 0x0000000000000318 0x0000000000000318
                 0x000000000000001c 0x000000000000001c  R      0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000640 0x0000000000000640  R      0x1000
  LOAD           0x0000000000001000 0x0000000000001000 0x0000000000001000
                 0x0000000000000185 0x0000000000000185  R E    0x1000
  LOAD           0x0000000000002000 0x0000000000002000 0x0000000000002000
                 0x00000000000000f8 0x00000000000000f8  R      0x1000
  LOAD           0x0000000000002e10 0x0000000000003e10 0x0000000000003e10
                 0x0000000000000228 0x0000000000000230  RW     0x1000
  DYNAMIC        0x0000000000002e20 0x0000000000003e20 0x0000000000003e20
                 0x00000000000001d0 0x00000000000001d0  RW     0x8
  GNU_RELRO      0x0000000000002e10 0x0000000000003e10 0x0000000000003e10
                 0x00000000000001f0 0x00000000000001f0  R      0x1
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x10

 Section to Segment mapping:
  Segment Sections...
   00
   01     .interp
   02     .interp .note.gnu.property .note.gnu.build-id .gnu.hash .dynsym .dynstr
   03     .init .plt .plt.sec .text .fini
   04     .rodata .eh_frame_hdr .eh_frame
   05     .init_array .fini_array .dynamic .got .data .bss
   06     .dynamic
   07     .data.rel.ro

শেষের “Section to Segment mapping” টেবিলটাই এই লেসনের কেন্দ্রীয় প্রমাণ — চোখে দেখা many-to-one সম্পর্ক। Segment 03-এ পাঁচটা section (.init, .plt, .plt.sec, .text, .fini) — সবগুলোই কোড, সবারই একই permission দরকার (R-E, read + execute, write না)। Segment 05-এ ছয়টা section — সবাই লেখার-যোগ্য ডেটা (RW), যার মধ্যে .bss-ও আছে যদিও তার FileSiz অন্যদের চেয়ে ছোট।

লক্ষ্য করুন শেষ LOAD segment-এর FileSiz (0x228) MemSiz-এর (0x230) চেয়ে ছোট — এই 0x8-byte পার্থক্যটাই .bss-এর অবদান: ফাইলে যতটুকু আছে তার চেয়ে মেমরিতে একটু বেশি বরাদ্দ হবে, বাড়তি অংশটা zero-filled।

উদাহরণ

সম্পূর্ণ উদাহরণ — একটা ছোট প্রোগ্রামের প্রতিটা স্তর

// demo.c
#include <stdio.h>

int initialized_global = 42;      // .data
int uninitialized_global;         // .bss
const char *msg = "hello elf";    // pointer .data, string .rodata

int add(int a, int b) {           // .text
    return a + b;
}

int main(void) {
    printf("%s: %d\n", msg, add(initialized_global, uninitialized_global));
    return 0;
}
$ gcc -O2 -o demo demo.c
$ readelf -S demo | grep -E '\.text|\.data|\.bss|\.rodata|\.symtab|\.dynsym'
  [15] .rodata           PROGBITS         0000000000002000  00002000
  [17] .text             PROGBITS         0000000000001040  00001040
  [24] .data             PROGBITS         0000000000004010  00003010
  [25] .bss              NOBITS           0000000000004020  00003020
  [ 6] .dynsym           DYNSYM           0000000000000368  00000368

.symtab এখানে নেই কারণ gcc -O2 -o demo demo.c-এর ডিফল্ট আউটপুট strip করা না, কিন্তু dynamically-linked executable-এ .symtab মাঝে মাঝে থাকে মাঝে মাঝে না — .dynsym (dynamic symbol table, GOT/PLT resolve করতে dynamic linker-এর যা লাগে) সবসময় থাকে যদি dynamically linked হয়।

$ objdump -d demo --disassemble=add

0000000000001149 <add>:
    1149:	01 f7                	add    edi,esi
    114b:	89 f8                	mov    eax,edi
    114d:	c3                   	ret

readelf -x .data demo দিয়ে raw bytes দেখা:

$ readelf -x .data demo

Hex dump of section '.data':
  0x00004010 2a000000 00000000                    *.......

0x2a = 42 (decimal) — ঠিক initialized_global-এর মান, লিটল-এন্ডিয়ানে। পরের ৪ byte অন্য কোনো initialized global-এর অংশ বা padding।

$ readelf -x .bss demo
readelf: Warning: Section '.bss' has no data to dump.

এইটাই .bss সম্পর্কে সবচেয়ে সরাসরি প্রমাণ — ফাইলে সত্যিই কোনো byte নেই dump করার মতো, যদিও readelf -S-এ .bss-এর Size নন-জিরো দেখায়।

demo.c → চলমান প্রসেস — কোন তথ্য কোথায় বাস করে
  1. demo.c — সোর্স কোডvariable আর function-এর নাম আছে, ঠিকানা নেই
  2. demo.o — assembler-এর আউটপুট (ET_REL)section আছে, কিন্তু ঠিকানা এখনো relative-to-zero
  3. demo — linker-এর আউটপুট (ET_DYN, PIE)section চূড়ান্ত, program header (segment) যোগ হলো
  4. execve() — kernel loaderশুধু program header পড়ে, LOAD segment-গুলো mmap করে
  5. চলমান প্রসেস — /proc/pid/maps.text+.rodata একটা mapping (R-E), .data+.bss আরেকটা (RW)

নিজে চালিয়ে দেখুন

EXPERIMENT

.bss-এর ফাইল-সাশ্রয় সরাসরি মাপা

Linux (gcc, readelf, ls)· ১০ মিনিট
// big_uninit.c
int big_array[10 * 1024 * 1024];   // 40 MB, uninitialized → .bss
int main(void) { return big_array[0]; }
// big_init.c
int big_array[10 * 1024 * 1024] = {1};   // একটাও non-zero initializer থাকলেই .data
int main(void) { return big_array[0]; }
gcc -O0 -o big_uninit big_uninit.c
gcc -O0 -o big_init big_init.c
ls -la big_uninit big_init
readelf -S big_uninit | grep -E '\.data|\.bss'
readelf -S big_init   | grep -E '\.data|\.bss'

সাধারণ ফলাফল:

-rwxr-xr-x 1 user user   16624 big_init
-rwxr-xr-x 1 user user   16624 big_uninit

  [24] .data             PROGBITS         ...  Size 0000000000000000    (big_uninit)
  [25] .bss              NOBITS           ...  Size 0000000000a00010    (big_uninit — ~40 MB!)

  [24] .data             PROGBITS         ...  Size 0000000000a00010    (big_init — ~40 MB)
  [25] .bss              NOBITS           ...  Size 0000000000000000    (big_init)

ls -la প্রথমে সমান আকার দেখাচ্ছে বিভ্রান্তিকরভাবে — কারণ -O0-এ compiler এখনো সরাসরি linking করেনি ফাইলের tail truncate করে; আসল পার্থক্যটা readelf -S-এর .bss বনাম .data size-এ স্পষ্ট। একটা আরও সরাসরি demonstration:

du -h big_uninit big_init          # disk ব্যবহার
stat --format='%s bytes' big_uninit big_init

40 MB-এর array .bss-এ ফাইলের আকারে প্রায় কিছুই যোগ করে না (Size metadata হিসেবে থাকে, byte হিসেবে না), কিন্তু .data-এ গেলে ফাইলে সত্যিই ৪০ মেগাবাইট বসে যায়। বড় স্কেলে (কল্পনা করুন কেউ একটা বিশাল lookup table declare করেছে কিন্তু ভুলবশত একটা = {0} initializer দিয়ে দিয়েছে) এই পার্থক্যটা একটা বাইনারির আকারকে কয়েক মেগাবাইট থেকে কয়েক গিগাবাইটে নিয়ে যেতে পারে — বাস্তব bug, বাস্তব build-time সমস্যা।

এটা কী প্রমাণ করে

একটা বিশাল uninitialized array ফাইলের আকার প্রায় বাড়ায়ই না, কিন্তু একটা initialized array-এর সমান আকার সরাসরি ফাইলে যোগ হয় — .bss বনাম .data-এর পার্থক্য বাস্তবে পরিমাপযোগ্য।

নিজে বানান

BUILD IT

Section Header Dumper — নিজের mini-readelf

Python · ●●●○○
  1. ELF header পার্স করে e_shoff, e_shentsize, e_shnum বের করুন
  2. সেই অফসেট থেকে প্রতিটা section header entry পড়ুন
  3. sh_name ইনডেক্স থেকে .shstrtab (section header string table) দিয়ে আসল নাম বের করুন
  4. প্রতিটা section-এর নাম, type, address, offset, size টেবিল আকারে প্রিন্ট করুন
  5. readelf -S-এর আসল আউটপুটের সাথে মিলিয়ে যাচাই করুন

মূল ধারণা: section header table নিজেও একটা array — e_shoff-এ শুরু, প্রতিটা entry e_shentsize byte, মোট e_shnumটা। প্রতিটা entry-র sh_name ফিল্ড একটা নাম নয়, বরং .shstrtab নামের আরেকটা section-এর ভেতরে একটা byte-offset, যেখানে null-terminated নামটা আছে — এই এক-ধাপ পরোক্ষতাই কেন সব section-এর নাম আলাদাভাবে সংরক্ষণ না করে একটা কেন্দ্রীয় string table-এ রাখা হয় (পুনরাবৃত্তি এড়াতে, ঠিক যেমন symbol table-এর নামও .strtab-এ রাখা হয়)।

import struct
import sys

SH_TYPES = {
    0: "NULL", 1: "PROGBITS", 2: "SYMTAB", 3: "STRTAB",
    4: "RELA", 8: "NOBITS", 9: "REL", 11: "DYNSYM",
}

def read_cstr(data, offset):
    end = data.index(b"\x00", offset)
    return data[offset:end].decode("utf-8", errors="replace")

def dump_sections(path):
    with open(path, "rb") as f:
        data = f.read()

    assert data[:4] == b"\x7fELF", "ELF ফাইল না"
    is_64 = data[4] == 2

    if not is_64:
        raise NotImplementedError("এই স্ক্রিপ্ট শুধু 64-bit ELF সমর্থন করে")

    e_shoff    = struct.unpack_from("<Q", data, 0x28)[0]
    e_shentsize = struct.unpack_from("<H", data, 0x3a)[0]
    e_shnum    = struct.unpack_from("<H", data, 0x3c)[0]
    e_shstrndx = struct.unpack_from("<H", data, 0x3e)[0]

    # প্রথমে .shstrtab section-এর নিজের অফসেট বের করি
    shstr_entry_off = e_shoff + e_shstrndx * e_shentsize
    shstrtab_offset = struct.unpack_from("<Q", data, shstr_entry_off + 24)[0]

    print(f"{'নাম':<18} {'Type':<10} {'Address':>16} {'Offset':>10} {'Size':>10}")
    print("-" * 70)

    for i in range(e_shnum):
        off = e_shoff + i * e_shentsize
        sh_name   = struct.unpack_from("<I", data, off + 0)[0]
        sh_type   = struct.unpack_from("<I", data, off + 4)[0]
        sh_addr   = struct.unpack_from("<Q", data, off + 16)[0]
        sh_offset = struct.unpack_from("<Q", data, off + 24)[0]
        sh_size   = struct.unpack_from("<Q", data, off + 32)[0]

        name = read_cstr(data, shstrtab_offset + sh_name)
        type_name = SH_TYPES.get(sh_type, f"?({sh_type})")

        print(f"{name:<18} {type_name:<10} {sh_addr:>16x} {sh_offset:>10x} {sh_size:>10x}")


if __name__ == "__main__":
    dump_sections(sys.argv[1])
$ python3 section_dumper.py demo
নাম                Type       Address     Offset       Size
----------------------------------------------------------------
                   NULL                0          0          0
.interp            PROGBITS          318        318         1c
.text              PROGBITS         1040       1040        185
.rodata            PROGBITS         2000       2000         18
.data              PROGBITS         4010       3010          8
.bss               NOBITS           4020       3020          4
.symtab            SYMTAB              0       4210        480
.strtab            STRTAB              0       4690        1a3
.shstrtab          STRTAB              0       4833        c9

readelf -S demo-এর সাথে পাশাপাশি রেখে প্রতিটা সারি মিলিয়ে দেখুন — Type, Address, Offset, Size চারটাই মিলে যাওয়া উচিত।

নিজে বাড়ান:

  1. Program header table-ও একইভাবে পার্স করুন (e_phoff, e_phnum), segment type আর permission flag প্রিন্ট করুন
  2. .bss-কে (type NOBITS) বিশেষভাবে চিহ্নিত করুন আউটপুটে, আর পাশে দেখান “ফাইলে ০ byte, মেমরিতে sh_size byte”
  3. Section-to-segment mapping নিজে গণনা করুন — কোন section-এর address range কোন LOAD segment-এর range-এর ভেতরে পড়ে তা মিলিয়ে
  4. Symbol table (.symtab) পার্স করে function/variable নামের তালিকা বের করুন — এটাই মূলত nm কমান্ড যা করে

বাস্তব সিস্টেমে

ELF যেখানে সিদ্ধান্ত নেয়

Linux kernel-এর execve() যখন আপনি ./program চালান, kernel-এর binfmt_elf module প্রথমে magic bytes চেক করে, তারপর program header পড়ে প্রতিটা PT_LOAD segment-কে সরাসরি mmap() করে — section header এই পুরো প্রক্রিয়ায় স্পর্শও হয় না। এই কারণেই একটা strip-করা বাইনারি (section header table নেই) তবু নিখুঁতভাবে চলে।

Core dump — ET_CORE একটা প্রোগ্রাম crash করলে (segfault), kernel চাইলে তার পুরো memory image একটা core ফাইলে লেখে — সেটাও ELF, e_type = ET_CORE, আর segment-গুলো এবার “এই মুহূর্তে মেমরিতে কী ছিল” ধারণ করে। gdb program core দিয়ে সেই মুহূর্তের অবস্থা post-mortem analyze করা যায় — পরের লেসনে আমরা লাইভ debugging দেখব, কিন্তু core dump একই ELF ভিত্তির উপর দাঁড়িয়ে “মৃত্যুর মুহূর্ত” ধরে রাখে।

Debug info — .debug_* sections, DWARF। Source-level debugging (লাইন নম্বর, variable নাম, type তথ্য) সংরক্ষিত হয় .debug_info, .debug_line ইত্যাদি নামের section-এ, DWARF ফরম্যাটে এনকোড করা। gcc -g এই section-গুলো যোগ করে; strip সেগুলো ফেলে দেয়। পরের লেসনে gdb যখন “কোন লাইনে আছি” দেখায়, সেটা এই section থেকেই আসে।

Android-এর .so ফাইল। প্রতিটা Android app-এর native code (libnative-lib.so) একটা সাধারণ ELF shared object, ARM64 বা x86-64 target করে — Android Runtime (ART) সেই একই ELF loader logic ব্যবহার করে যা এই লেসনে দেখানো হলো, শুধু Machine ফিল্ড ভিন্ন CPU নির্দেশ করে।

Firmware আর embedded ELF। Bootloader (U-Boot), Linux kernel image (vmlinux), এমনকি কিছু microcontroller firmware — সবই ELF-এ কম্পাইল হয়, পরে একটা raw binary-তে রূপান্তরিত হয় flash করার আগে (objcopy -O binary), কারণ chip নিজে ELF parse করতে জানে না, শুধু raw bytes flash-এ লেখা যায়।

Malware analysis আর static analysis টুল। radare2, Ghidra, IDA Pro — সব ELF parsing দিয়ে শুরু করে: section/segment মানচিত্র বানিয়ে, symbol table দেখে কোন function কোথায়, .dynsym/relocation দেখে কোন বাইরের function call হচ্ছে (গত লেসনের PLT/GOT বিশ্লেষণ, এখন malware-এ কী libc function ব্যবহার হচ্ছে বোঝার জন্য)।

strip আর বাইনারি আকার কমানো। Production বিল্ডে প্রায়ই strip binary চালানো হয় — .symtab, .debug_* এর মতো section মুছে ফেলে (যেগুলো শুধু debugging-এর জন্য, execution-এর জন্য না)। .dynsym থাকে (dynamic linking-এর জন্য দরকার), কিন্তু .symtab চলে যায় — বাইনারি ছোট হয়, আর reverse-engineering কিছুটা কঠিন হয় (যদিও নিরাপত্তার প্রকৃত ব্যবস্থা না, শুধু বাধা)।

যে ভুলগুলো সবাই করে

“ELF মানে শুধু executable ফাইল।”

নামটাই ভুল ধারণা তৈরি করে — “Executable and Linkable Format”-এর দ্বিতীয় শব্দটা প্রায়ই উপেক্ষিত হয়। এই লেসনের e_type টেবিলটাই প্রমাণ: ET_REL (object file, এখনো linkable, executable না), ET_DYN (shared library, নিজে থেকে চলে না), ET_EXEC/PIE executable, ET_CORE (crash snapshot) — চারটাই ELF, মাত্র একটা ET_EXEC-এর মতো ধরনের।

আসলে pipeline-এর প্রতিটা বাইনারি artifact — .o, .so, চূড়ান্ত executable, এমনকি একটা crash-এর পরে core dump — সবই একই format-এর variation।

“Section header না থাকলে ফাইলটা চলবে না।”

বিপরীত সত্য। execve() (আর আসলে dynamic linker ld.so-ও) শুধু program header (segment) পড়ে, section header একদমই স্পর্শ করে না। একটা strip-করা executable-এ section header table সম্পূর্ণ অনুপস্থিত থাকতে পারে (e_shnum = 0), তবু নিখুঁতভাবে চলে।

Section header দরকার শুধু build-time টুল (linker) আর analysis টুলের (readelf, gdb debug-info lookup, disassembler) জন্য — runtime-এর জন্য না। এই পার্থক্যটাই “section (build/analysis) বনাম segment (runtime)“-এর মূল কথা, শুধু একটা বিমূর্ত নিয়ম না — একটা বাস্তব, পরীক্ষাযোগ্য পরিণতি (strip --strip-all করে চালিয়ে দেখুন)।

“.bss একটা section-এর মতোই normal, শুধু নাম আলাদা।”

readelf -S-এর টেবিলে .bss-কে অন্য যেকোনো section-এর মতোই একটা সারি দেখায় — কিন্তু তার Type (NOBITS) বাকি সবার (PROGBITS) থেকে গুণগতভাবে ভিন্ন। PROGBITS section-এর জন্য ফাইলে সত্যিই সেই আকারের byte আছে — readelf -x দিয়ে dump করা যায়। .bss-এর জন্য readelf -x করলে “no data to dump” — কারণ ফাইলে কোনো byte-ই নেই, শুধু “লোড হওয়ার সময় এত byte শূন্য দিয়ে ভরে দাও” এই metadata।

এই পার্থক্যটা practically গুরুত্বপূর্ণ: একটা 40 MB uninitialized array ফাইলের আকারে প্রায় কিছুই যোগ করে না (এই লেসনের experiment), কিন্তু একই আকারের initialized array (এমনকি সব-শূন্য দিয়ে initialize করলেও, compiler optimization না থাকলে) .data-এ যায় আর সত্যিই ৪০ মেগাবাইট ফাইলে বসে যায়।

বুঝেছেন কি না দেখুন

1

readelf -h-এ দুইটা ফাইল দেখলেন — একটার Type: REL, আরেকটার Type: DYN। শুধু এই একটা তথ্য থেকে পাইপলাইনের কোন ধাপে ফাইল দুটো আছে, আর Entry point address ফিল্ডে কী আশা করা উচিত তা বলুন।

যুক্তি

ET_REL — এটা একটা object file (.o), অর্থাৎ assembler-এর আউটপুট, এখনো linked হয়নি। এই ফাইলটা pipeline-এর মাঝামাঝি — compile/assemble হয়ে গেছে কিন্তু linker এখনো একাধিক .o মিলিয়ে একটা চূড়ান্ত বাইনারি বানায়নি।

ET_REL-এ Entry point address অর্থহীন, সাধারণত 0x0 — কারণ একটা relocatable object file নিজে থেকে চলে না, কোথা থেকে execution শুরু হবে সেই প্রশ্নটাই এখনো প্রাসঙ্গিক না। এই ফাইলে হয়তো main function-ই নেই (অন্য কোনো .o-তে আছে), বা থাকলেও এটা এখনো একটা “খণ্ড”, পুরো প্রোগ্রাম না।

ET_DYN — এটা linking-পরবর্তী: শেয়ার্ড লাইব্রেরি অথবা PIE executable (আগের লেসনের PT_INTERP টেস্ট দিয়ে আলাদা করতে হবে কোনটা)। এখানে Entry point address একটা বাস্তব, অর্থবহ মান দেখাবে — যদি এটা PIE executable হয়, সেই ঠিকানাটাই loader-এর jump করার জায়গা (execution শুরুর বিন্দু, লোড-base-এর সাপেক্ষে আপেক্ষিক, যেহেতু ET_DYN)। যদি এটা plain shared library হয়, Entry point address থাকলেও সেটা সরাসরি ব্যবহার হয় না (library নিজে থেকে “শুরু” হয় না) — তবে DT_INIT-এর মতো initialization hook থাকতে পারে।

2

readelf -S-এ একটা section-এর Offset = 0x3010, Size = 0x8, আর Type = PROGBITS। পরের section-এর Offset = 0x3020, Type = NOBITS, Size = 0x4। তার পরের section-এর Offset কত হবে বলে আশা করেন, আর কেন?

প্রয়োগ

প্রথম section (PROGBITS, offset 0x3010, size 0x8) ফাইলে বাস্তব byte দখল করে — শেষ হয় 0x3010 + 0x8 = 0x3018-এ। কিন্তু তালিকায় পরের section-এর offset 0x3020 — মাঝে 0x3018 থেকে 0x3020 পর্যন্ত ৮ byte gap, সম্ভবত alignment padding (section-গুলো প্রায়ই 8 বা 16-byte boundary-তে align করা থাকে)। এটা প্রথম clue যে দ্বিতীয় section সম্ভবত .bss

দ্বিতীয় section-এর Type = NOBITS নিশ্চিত করে এটা .bss (বা .bss-এর মতো একটা section) — এর Size = 0x4 মানে মেমরিতে ৪ byte লাগবে, কিন্তু ফাইলে এর জন্য কোনো byte বরাদ্দ নেই। যেহেতু NOBITS section ফাইলে কোনো জায়গা দখল করে না, তৃতীয় section-এর Offset দ্বিতীয় section-এর Offset-এর সমানই হবে0x3020

এটাই NOBITS-এর সংজ্ঞাগত পরিণতি: NOBITS section-এর Size পরবর্তী section-এর file offset নির্ধারণে কোনো ভূমিকা রাখে না, কারণ ফাইলে তার কোনো “জায়গা” নেই — শুধু একটা সংখ্যা (কতটা মেমরি দরকার) যা loader ব্যবহার করবে, ফাইল-লেআউট গণনায় না।

3

একটা section, .text ধরুন, permission AX (Alloc + eXecutable)। আরেকটা, .rodata, permission শুধু A (Alloc, লেখা যাবে না, চালানো যাবে না)। readelf -l-এর “Section to Segment mapping”-এ দেখা যায় দুটোই একই LOAD segment-এ (permission R E) গোষ্ঠীবদ্ধ। এটা কি নিরাপত্তার দিক থেকে সমস্যা — .rodata-কে কি ভুলবশত executable বানিয়ে দেওয়া হলো?

যুক্তি

হ্যাঁ, প্রযুক্তিগতভাবে এটাই ঘটছে, আর এটা একটা পরিচিত trade-off, বাগ না।

Segment-এর permission R E-তে দুটোরই মধ্যে সবচেয়ে permissive মিলিত মান নেওয়া হয় — .text চাই RX, .rodata চাই R, একসাথে একই page-এ থাকলে গোটা page-টাকে RX হতে হবে (linker কোনো section-কে তার প্রকৃত প্রয়োজনের চেয়ে কম access দিতে পারে না)। ফলে .rodata “technically” execute-able হয়ে যায়, যদিও কোনো কোড সেখান থেকে চালানোর কথা না।

এটা কেন গ্রহণযোগ্য: বাস্তব আক্রমণের ঝুঁকি কম, কারণ .rodata-এ সাধারণত string literal আর constant থাকে — একজন আক্রমণকারীর সেখানে “কার্যকর” শেল-কোড বসাতে হলে প্রথমে লেখার ক্ষমতা লাগবে, যা তার নেই (.rodata কখনো W পায় না, এমনকি একই segment-এ .text-এর সাথে মিশে থাকলেও)। আসল নিরাপত্তা boundary হলো W^X (write XOR execute) — একটা page কখনো একই সাথে writable আর executable হবে না — আর সেটা এখানে বজায় আছে।

কেন আলাদা segment না করে একসাথে রাখা হলো: যদি .rodata-কে আলাদা R-only segment-এ রাখা হতো, একটা বাড়তি page-aligned mapping লাগত — মেমরি আর syscall overhead বাড়ত সামান্য নিরাপত্তা লাভের বিনিময়ে যা বাস্তবে তেমন কিছু আটকায় না। এটাই linker script-এর একটা ইচ্ছাকৃত engineering trade-off: “একই read/execute-permission profile-এর section-গুলো একসাথে গোষ্ঠীবদ্ধ করো, শুধু write-permission-এর পার্থক্যেই আলাদা segment বানাও (W^X বজায় রাখতে)।”

4

একটা টুল বানাচ্ছেন যেটা একটা ELF ফাইল “malicious” কিনা প্রাথমিক স্ক্রিনিং করে — শুধু কতগুলো বাইরের function (libc-এর মতো) call হচ্ছে তার সংখ্যা আর নাম গোনে, execute না করেই। কোন section(গুলো) পড়বেন, আর কেন section-এর বদলে segment না?

ডিজাইন

পড়া দরকার .dynsym (dynamic symbol table) আর .rela.plt / .rela.dyn (relocation entries) — গত লেসনের PIC/GOT/PLT লেসনের সরাসরি প্রয়োগ।

.dynsym-এ প্রতিটা বাইরের symbol-এর নাম আছে যা এই বাইনারি import করে (printf, system, strcpy, ইত্যাদি) — .rela.plt-এর R_X86_64_JUMP_SLOT entry গুলো এই symbol-গুলোকেই GOT slot-এর সাথে যুক্ত করে (গত লেসনের readelf -r আউটপুট ঠিক এটাই দেখিয়েছিল)। এই দুটো টেবিল একসাথে পড়লে “এই বাইনারি কোন কোন বাইরের ফাংশন ব্যবহার করে” সম্পূর্ণ তালিকা পাওয়া যায় — যেমন system() বা exec*()-এর মতো সন্দেহজনক ফাংশন দেখলে একটা সতর্কতা flag তোলা যায়।

কেন section, segment না: এই তথ্য — “কোন symbol-এর নাম কী” — আসলে build-time/analysis-time metadata, যা কোনো runtime loading সিদ্ধান্তে সরাসরি প্রয়োজন হয় না, বরং কোন symbol নাম কী তা মানুষ বা টুল বোঝার জন্য সংরক্ষিত (dynamic linker নিজেও আসলে .dynsym-ই পড়ে symbol resolve করতে — কিন্তু সেটাও build-time-এ প্রস্তুত করা একটা named table, program header-এ নেই)। Program header/segment শুধু “কতটুকু মেমরি, কোন permission” বলে — কোনো নাম, কোনো symbol তথ্য বহন করে না। আপনার টুলের প্রশ্ন (“কোন function”) নামভিত্তিক, তাই section-ই সঠিক জায়গা।

সতর্কতা: যদি বাইনারিটা strip-করা হয় (.symtab নেই), .dynsym তবু থাকবে যদি এটা dynamically linked হয় — কারণ dynamic linking নিজেই runtime-এ symbol নাম দিয়ে resolve করে (গত লেসনের PLT resolver), তাই .dynsym সাধারণ strip দিয়ে সরানো যায় না, শুধু .symtab (যা শুধুই debugging/analysis-এর জন্য) সরানো যায়।

5

“Section header আর program header — দুটোই ELF ফাইলের ভেতরে থাকা টেবিল যা বলে দেয় ফাইলে কী কী আছে, তাই তারা কার্যত একই জিনিসের দুইটা নাম” — এই দাবিটা কেন ভুল? দুই বাক্যে বলুন।

স্মরণ

ভুল, কারণ তাদের পাঠক আর উদ্দেশ্য সম্পূর্ণ আলাদা: section header টেবিল linker আর analysis টুলের জন্য, সূক্ষ্ম-দানার নাম-করা অংশ (.text, .symtab, …) বর্ণনা করে যা build-time-এ ও offline-বিশ্লেষণে দরকার; program header টেবিল loader/kernel-এর জন্য, মোটা-দানার নামহীন memory segment (শুধু permission ও range) বর্ণনা করে যা শুধুমাত্র runtime-এ execve()/mmap()-এর সময় লাগে।

দ্বিতীয় প্রমাণ: এই লেসনে দেখানো হয়েছে একটা strip-করা বাইনারি section header সম্পূর্ণ ছাড়াই চলতে পারে (কারণ loader সেটা কখনো পড়েই না), কিন্তু program header বাদ দিলে বাইনারিটা চলবেই না (execve() জানবে না কোথায় কী map করতে হবে) — যদি দুটো টেবিল সত্যিই “একই জিনিসের দুই নাম” হতো, একটা বাদ দিলে অন্যটাও একই রকম গুরুত্বপূর্ণ হতো।

এরপর কী

পরের ধাপ — এই স্থির নকশা একটা চলমান প্রসেসে দেখা

এই দুই লেসনে আমরা একটা বাইনারির স্থির (static) গঠন দেখেছি — ফাইলে কী কী section আছে, কোন segment-এ কী map হবে, GOT/PLT কোথায় বসে, .bss কীভাবে ফাইলে সংরক্ষিত না হয়েও মেমরিতে জায়গা পায়। সবকিছু readelf, objdump দিয়ে — প্রোগ্রামটা না চালিয়েই

কিন্তু এই পুরো module-এর driving question ছিল: “আমার C function compile হয়ে ঠিক কোন instruction-গুলো হলো, আর কেন?” — সেই প্রশ্নের পূর্ণাঙ্গ উত্তর পেতে এখন দরকার প্রোগ্রামটাকে লাইভ দেখা — register আসলে কোন মুহূর্তে কোন মান ধরে, stack frame আসলে কীভাবে গড়ে ওঠে আর ভেঙে যায়, GOT entry সত্যিই কখন patch হয়।

পরের, এই module-এর শেষ লেসনে আমরা gdb দিয়ে ঠিক এটাই করব — এতক্ষণ যা কাগজে-কলমে (বা readelf-এর স্থির আউটপুটে) দেখেছি, তার প্রতিটা এখন একটা চলমান প্রসেসে, breakpoint দিয়ে থামিয়ে, সরাসরি চোখে দেখা।

আরও পড়ুন

  • System V Application Binary Interface — Generic ABI, Chapter 4 (Object Files) · ELF-এর প্রামাণ্য স্পেসিফিকেশন — header layout, section types, program header types
  • elf(5) — Linux manual page · man 5 elf — টার্মিনালেই এই লেসনের প্রতিটা struct-এর সংজ্ঞা
  • Linkers and Loaders — John R. Levine · Chapter 2 — object file format-এর সাধারণ নকশা-দর্শন, ELF তার একটা বাস্তবায়ন