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

Position-Independent Code — GOT আর PLT-এর ভেতরের গল্প

Position-Independent Code

শেয়ার্ড লাইব্রেরি যেকোনো ঠিকানায় লোড হতে পারে বলেই তার কোড RIP-relative addressing, GOT আর PLT ব্যবহার করে — আর এই একই কৌশল ASLR-কে বাস্তবায়নযোগ্য করে তোলে।

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

  • PIC ঠিক কোন সমস্যার সমাধান করে তা shared library আর ASLR-এর প্রেক্ষাপটে ব্যাখ্যা করতে পারবেন
  • RIP-relative addressing কীভাবে internal symbol-এর ঠিকানা লোড-address নির্বিশেষে সঠিক রাখে তা assembly উদাহরণ দিয়ে দেখাতে পারবেন
  • GOT আর PLT-এর ভূমিকা আলাদা করে বলতে পারবেন — কোনটা data reference-এর জন্য, কোনটা function call-এর জন্য
  • Lazy binding-এ PLT-এর প্রথম call আর পরের call-এর মধ্যে পার্থক্য ধাপে ধাপে ট্রেস করতে পারবেন
  • readelf আর objdump দিয়ে একটা shared library-তে GOT/PLT entry ও relocation শনাক্ত করতে পারবেন
  • PIC কীভাবে ASLR-কে বাস্তবায়নযোগ্য করে তোলে তা নিরাপত্তার দৃষ্টিকোণ থেকে ব্যাখ্যা করতে পারবেন

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

আগে এটা বুঝি

গত লেসনে inline assembly দিয়ে আমরা C-এর ভেতর সরাসরি instruction বসিয়েছি। প্রতিবারই ধরে নিয়েছি প্রোগ্রামটা একটা normal executable — compile হয়ে, লিংক হয়ে, সরাসরি চলে। কিন্তু আধুনিক প্রতিটা প্রোগ্রামই আসলে একা চলে না।

ldd দিয়ে যেকোনো বাইনারি দেখুন:

$ ldd /bin/ls
        linux-vdso.so.1 (0x00007ffd8a5f8000)
        libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007f2c4a1e3000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2c49ff0000)
        libpcre2-8.so.0 => /lib/x86_64-linux-gnu/libpcre2-8.so.0 (0x00007f2c49f5c000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f2c4a2a4000)

প্রতিটা .so একটা আলাদা ঠিকানায় loaded। আর এই ঠিকানাগুলো প্রতিবার ls চালালে বদলে যায় — এখনই আরেকবার ldd /bin/ls চালালে ভিন্ন হেক্স দেখবেন। কারণটা ইচ্ছাকৃত।

দুইটা কারণ আছে কেন libc.so.6-এর কোড একটা fixed, hardcoded ঠিকানায় লোড হতে পারে না:

১. Practicality। একই সময়ে চলা প্রতিটা প্রোগ্রামই libc ব্যবহার করে — Firefox, bash, ls, sshd, সবাই। যদি libc-কে একটা fixed ঠিকানা দাবি করতে হতো, আর দুইটা লাইব্রেরির fixed ঠিকানা কখনো সংঘর্ষ করত (যা অনিবার্য, শত শত shared library মিলিয়ে), পুরো সিস্টেম ভেঙে পড়ত। বহু লাইব্রেরিকে একই address space-এ শেয়ার করতে হলে, কেউই নিজের ঠিকানা নিয়ে জেদ করতে পারে না।

২. নিরাপত্তা — ASLR। যদি libc সবসময় একই ঠিকানায় লোড হতো, তাহলে একটা buffer-overflow আক্রমণকারী নিশ্চিতভাবে জানত system() ফাংশনটা ঠিক কোথায় — return-oriented programming (ROP) আক্রমণ তখন trivial হয়ে যেত। Address Space Layout Randomization (ASLR) প্রতিবার লোডের সময় লাইব্রেরির ঠিকানা randomize করে, ঠিক এই আক্রমণ কঠিন করতে। Level 10-এর Security module-এ আমরা এই আক্রমণ আর প্রতিরক্ষা বিস্তারিত দেখব — এই লেসনে শুধু বুঝব কেন এটা সম্ভবই হয়

দুইটা কারণই একই প্রশ্নে গিয়ে দাঁড়ায়: libgreet.so-র ভেতরের কোড, যেটা নিজেই নিজের একটা ফাংশনকে call করছে, বা নিজের একটা global variable পড়ছে — সেই কোড কীভাবে সঠিক থাকে যখন সে জানেই না কোথায় লোড হবে?

// libgreet.so-র ভেতরে
static int counter = 0;

void greet(void) {
    counter++;              // counter কোথায়? ঠিকানাটা কম্পাইল-টাইমে অজানা!
    printf("call #%d\n", counter);   // printf() libc-তে — সেটা কোথায়?
}

counter-এর absolute ঠিকানা compile-time-এ hardcode করা অসম্ভব — লোড-address নির্ভর করে runtime-এর উপর। এই সমস্যার সমাধানই এই লেসনের বিষয়: Position-Independent Code (PIC)

মূল ধারণা

মূল ধারণা — সব reference আপেক্ষিক হতে হবে

Addressing-modes লেসনে আমরা দেখেছিলাম PC-relative addressing — EA = PC + offset। PIC এই ধারণাটাকেই কেন্দ্রে রাখে: প্রোগ্রামের ভেতরের প্রতিটা reference — নিজের function, নিজের global variable — absolute ঠিকানা না ব্যবহার করে current instruction-এর সাপেক্ষে আপেক্ষিক ঠিকানা ব্যবহার করবে।

x86-64-এ এই mechanism-এর নাম RIP-relative addressing (RIP = Instruction Pointer register, x86-64-এ PC-র নাম)।

lea rax, [rip + 0x2e58]        # rax ← RIP + 0x2e58

0x2e58 টা compile/link-time-এ জানা একটা ধ্রুবক offset — “এই lea instruction থেকে target symbol কত byte দূরে”, অনেকটা গত লেসনে দেখা beq branch-এর offset-এর মতোই, শুধু এবার গন্তব্য একটা data symbol বা function, আরেকটা instruction না।

non-PIC বনাম PIC — পাশাপাশি

পুরনো দিনের (আর আজও কিছু static executable-এর) কোড সরাসরি absolute ঠিকানা ব্যবহার করত:

; non-PIC — absolute addressing, পুরনো 32-bit স্টাইল
mov eax, DWORD PTR ds:0x804a018     ; counter-এর ঠিকানা সরাসরি এনকোড করা

এই 0x804a018 instruction-এর bytes-এর ভেতরেই বসানো। প্রোগ্রামটা যদি অন্য ঠিকানায় লোড হয়, এই instruction ভুল জায়গায় পড়বে — তাই non-PIC কোডকে fixed address-এ লোড হতে হয় (executable-এর জন্য এটা সাধারণত সমস্যা না, কিন্তু shared library-র জন্য মারাত্মক, কারণ একাধিক প্রোগ্রাম একই library ভিন্ন ভিন্ন ঠিকানায় ব্যবহার করতে চাইবে)।

; PIC — RIP-relative addressing, আধুনিক x86-64 স্টাইল
lea rax, [rip + 0x2e58]             ; rax ← counter-এর ঠিকানা, আপেক্ষিক হিসেবে গণনা
mov DWORD PTR [rax], ...

এখানে 0x2e58 কোনো absolute ঠিকানা না — এটা “এই instruction থেকে counter কত byte দূরে”, একটা ধ্রুবক যা লোড-address নির্বিশেষে সত্য থাকে।

non-PIC:  instruction bytes ⊇ [absolute address of counter]
          লোড হলে ঠিক সেই ঠিকানাতেই counter থাকতে হবে

PIC:      instruction bytes ⊇ [distance from here to counter]
          লোড ঠিকানা যাই হোক, distance অপরিবর্তিত থাকে
একই global variable access — non-PIC-এ absolute ঠিকানা hardcode, PIC-এ RIP-relative distance।

দুই ধরনের symbol — internal বনাম external

কিন্তু সব reference সমান সরল না। counter-এর মতো একটা static variable-এর ঠিকানা এই লাইব্রেরির নিজের ভেতরে, তাই লিংকার compile-time-এই জানে “এখান থেকে counter ঠিক কত byte দূরে” — একটা সরাসরি lea যথেষ্ট।

কিন্তু printf? সেটা libgreet.so-র ভেতরে নেই — libc.so.6-এ আছে, যেটা একটা আলাদা shared library, নিজের একটা আলাদা লোড-address নিয়ে। libgreet.so compile হওয়ার সময় libc কোথায় লোড হবে জানার কোনো উপায় নেই — এমনকি এটাও নিশ্চিত না যে ঠিক libc-এরই printf চলবে, নাকি অন্য কোনো লাইব্রেরি সেই symbol interpose (override) করে দিয়েছে (LD_PRELOAD দিয়ে, নিচে দেখুন)।

এই দুই ধরনের প্রয়োজনের জন্যই দুইটা আলাদা টেবিল দরকার হয়।

GOT — Global Offset Table

GOT একটা প্রতি-প্রসেস টেবিল, শেয়ার্ড লাইব্রেরির নিজের data segment-এর ভেতরে বসে থাকে। প্রতিটা এন্ট্রি একটা resolved absolute address ধরে রাখে — বাইরের একটা symbol (variable বা function) আসলে runtime-এ কোথায় আছে।

কোডটা GOT-কে RIP-relative addressing দিয়েই খুঁজে পায় (GOT নিজেই লাইব্রেরির ভেতরে, তাই তার ঠিকানাও লোড-address-এর সাপেক্ষে ধ্রুবক দূরত্বে):

mov rax, QWORD PTR [rip + 0x2f20]   ; rax ← GOT-এ থাকা resolved absolute address
call rax                            ; সেই ঠিকানায় jump

এখানে দুই ধাপের indirection: [rip+offset] আপেক্ষিকভাবে GOT entry-র ঠিকানা বের করে, তারপর সেই entry-র ভেতরের মান-ই হলো আসল target — যেটা dynamic linker load-time-এ ঠিক করে বসিয়ে দেয়।

PLT — Procedure Linkage Table

Function call-এর জন্য একটা বাড়তি layer আছে — PLT, একটা ছোট stub-এর সংকলন, প্রতিটা বাইরের function-এর জন্য একটা করে। PLT দুটো কাজ করে:

১. প্রথমবার call হলে, dynamic linker-কে ডেকে lazily সেই function খুঁজে বের করায় (lazy binding — নিচে বিস্তারিত) ২. GOT entry patch হয়ে গেলে, পরের প্রতিটা call সরাসরি সেই resolved ঠিকানায় jump করে — dynamic linker আর ডাকা লাগে না

                        ┌─────────────────────────────┐
   কোড (greet)   ────►  │  data reference (counter):    │
                        │    lea rax, [rip+off]  ────►  counter (লাইব্রেরির নিজের ভেতর, direct)
                        │                               │
                        │  external data (extern var):  │
                        │    mov rax,[rip+got_off] ───► GOT[k] ──► আসল ঠিকানা (dynamic linker ভরে)
                        │                               │
                        │  external function (printf):  │
                        │    call printf@plt      ────► PLT[k] ──► GOT[k] ──► আসল printf() (dynamic linker ভরে)
                        └─────────────────────────────┘
GOT আর PLT-এর দ্বৈত কাঠামো — data-র জন্য শুধু GOT, function call-এর জন্য PLT+GOT দুটোই।

ভেতরে কী ঘটছে

Lazy binding — ধাপে ধাপে

libgreet.so-র greet() ফাংশন printf@plt কল করে। এই একটা call-এর জীবনচক্র দুইবার আলাদা।

প্রথমবার call — resolve করতে হবে:

call printf@plt


PLT[printf]:  jmp [rip + got_offset]     ; GOT entry এখনো unresolved


              GOT[printf] → PLT[0]-কে point করছে (এখনো!)


PLT[0]:       push [rip + got_offset_1]  ; link_map identifier push
              jmp [rip + got_offset_2]   ; dynamic linker-এর resolver-এ jump


        ld.so-র _dl_runtime_resolve()
        libc.so.6 স্ক্যান করে printf() খুঁজে বের করে
        GOT[printf]-কে সেই আসল ঠিকানা দিয়ে **patch** করে


        সেই আসল printf()-এই jump করে — কাজ শেষ হয়

দ্বিতীয়বার (আর তার পরের প্রতিটা) call — সরাসরি:

call printf@plt


PLT[printf]:  jmp [rip + got_offset]


              GOT[printf] → এখন সরাসরি আসল printf()-কে point করছে


              printf() — সরাসরি jump, resolver আর লাগে না
printf@plt একটা call — প্রথমবার বনাম শতবার পরে
  1. greet() এর ভেতর call printf@pltউৎস instruction — উভয় ক্ষেত্রেই অভিন্ন bytes
  2. PLT stub — jmp [rip+got_offset]GOT entry-র বর্তমান মানে jump — যা-ই থাকুক
  3. প্রথমবার: GOT এখনো PLT[0]-কে point করেresolver ডাকা হয়, ~কয়েক হাজার cycle খরচ
  4. resolver GOT entry patch করেএকবারই ঘটে, প্রতি symbol-এ একবার
  5. পরের বার: GOT সরাসরি printf()-কে point করেএক indirect jump, resolver skip — কয়েক cycle

কেন lazy — প্রোগ্রাম startup-এ যদি প্রতিটা import করা symbol সাথে সাথে resolve করতে হতো, বড় প্রোগ্রামে (হাজার হাজার symbol) startup সময় উল্লেখযোগ্য বেড়ে যেত, অথচ অনেক ফাংশন কখনোই call নাও হতে পারে। Lazy binding সেই খরচ postpone করে — শুধু যেটা আসলে ব্যবহার হয়, শুধু সেটাই resolve হয়।

readelf দিয়ে GOT/PLT দেখা

$ readelf -d libgreet.so | grep -E 'PLTGOT|JMPREL|PLTRELSZ'
 0x0000000000000003 (PLTGOT)            0x3fd8
 0x0000000000000002 (PLTRELSZ)          24 (bytes)
 0x0000000000000017 (JMPREL)            0x558

$ readelf -r libgreet.so

Relocation section '.rela.plt' at offset 0x558 contains 1 entry:
  Offset            Info             Type               Sym. Value    Sym. Name
000000003ff8  000200000007 R_X86_64_JUMP_SLOT     0000000000000000 printf@GLIBC_2.2.5 + 0

Relocation section '.rela.dyn' at offset 0x540 contains 3 entries:
  Offset            Info             Type               Sym. Value    Sym. Name
000000003fe8  000100000006 R_X86_64_GLOB_DAT      0000000000000000 __gmon_start__ + 0
000000004018  000000000008 R_X86_64_RELATIVE                        
000000004020  000000000008 R_X86_64_RELATIVE                        

R_X86_64_JUMP_SLOT — এইটাই PLT-এর জন্য GOT entry, printf-এর জন্য, lazily resolve হবে। R_X86_64_GLOB_DAT — একটা external data symbol-এর GOT entry। R_X86_64_RELATIVE — সবচেয়ে সস্তা relocation ধরন, শুধু “লোড base + একটা ধ্রুবক offset”, কোনো symbol lookup ছাড়াই dynamic linker সেট করে দেয় (internal symbol-এর জন্য, যেমন counter)।

objdump -d দিয়ে PLT stub নিজেই দেখা যায়:

$ objdump -d libgreet.so --disassemble=printf@plt

Disassembly of section .plt.sec:

0000000000001020 <printf@plt>:
    1020:	f3 0f 1e fa          	endbr64
    1024:	f2 ff 25 d5 2f 00 00 	bnd jmp *0x2fd5(%rip)        # 4000 <printf@GLIBC_2.2.5>
    102b:	0f 1f 44 00 00       	nop    DWORD PTR [rax+rax*1+0x0]

bnd jmp *0x2fd5(%rip) — ঠিক এইটাই আমাদের figure-এর “PLT[printf]: jmp [rip+got_offset]” লাইন, বাস্তব bytes-এ। (bnd prefix Intel MPX bounds-checking-এর জন্য, আধুনিক binutils ডিফল্ট এনকোড করে, functionally উপেক্ষা করা যায়।)

উদাহরণ

সম্পূর্ণ ট্রেস — সোর্স থেকে relocation পর্যন্ত

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

static int counter = 0;         // internal — RIP-relative direct
int enabled = 1;                 // external-visible — GOT-এর মধ্য দিয়ে

void greet(const char *name) {
    if (!enabled) return;
    counter++;
    printf("Hello, %s! (call #%d)\n", name, counter);   // printf — PLT-এর মধ্য দিয়ে
}
$ gcc -shared -fPIC -O2 -o libgreet.so libgreet.c
$ objdump -d libgreet.so --disassemble=greet

0000000000001139 <greet>:
    1139:	f3 0f 1e fa          	endbr64
    113d:	48 8b 05 c4 2e 00 00 	mov    rax,QWORD PTR [rip+0x2ec4]   # 4008 <enabled>
    1144:	83 38 00             	cmp    DWORD PTR [rax],0x0
    1147:	74 26                	je     116f <greet+0x36>
    1149:	48 8d 05 c8 2e 00 00 	lea    rax,[rip+0x2ec8]             # 4018 <counter>
    1150:	8b 10                	mov    edx,DWORD PTR [rax]
    1152:	83 c2 01             	add    edx,0x1
    1155:	89 10                	mov    DWORD PTR [rax],edx
    1157:	48 8d 35 a0 0e 00 00 	lea    rsi,[rip+0xea0]              # 1ffe "Hello, %s! (call #%d)\n"
    115e:	48 89 fa             	mov    rdx,rdi
    1161:	89 d1                	mov    ecx,edx
    1163:	48 89 f7             	mov    rdi,rsi
    1166:	31 c0                	xor    eax,eax
    1168:	e8 b3 fe ff ff       	call   1020 <printf@plt>
    116f:	c3                   	ret

তিনটা ভিন্ন pattern পাশাপাশি:

LineSymbolPatternকেন
mov rax,[rip+0x2ec4]enabledGOT-এর মধ্য দিয়ে (indirect load)non-static — অন্য .so থেকে interpose হতে পারে
lea rax,[rip+0x2ec8]counterসরাসরি RIP-relativestatic — লিংকার নিশ্চিত জানে এই library-র ভেতরেই
call 1020 <printf@plt>printfPLT-এর মধ্য দিয়েexternal function, libc-তে সংজ্ঞায়িত

PIE executable — একই কৌশল main program-এও

শুধু .so না — আধুনিক Linux distro-তে সাধারণ executable-ও এখন PIE (Position-Independent Executable) হিসেবে কম্পাইল হয়, ঠিক shared library-র মতোই, যাতে executable নিজেও ASLR পায়:

$ gcc -o normal_exe main.c            # আধুনিক gcc-তে PIE ডিফল্ট
$ file normal_exe
normal_exe: ELF 64-bit LSB pie executable, x86-64, ...

$ gcc -no-pie -o old_style main.c     # স্পষ্টভাবে non-PIE
$ file old_style
old_style: ELF 64-bit LSB executable, x86-64, ...

pie executable বনাম executablefile কমান্ডের এই একটা শব্দই বলে দেয় প্রতিবার চালালে এর কোড segment-ও randomize হবে কি না।

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

EXPERIMENT

PIC বনাম non-PIC — একই কোড, দুই রকম assembly

Linux (gcc, objdump, readelf)· ১৫ মিনিট
// probe.c
int global_var = 42;

int get_global(void) {
    return global_var;
}
# non-PIC — পুরনো স্টাইল absolute addressing (শুধু ব্যাখ্যার জন্য, আজকাল বিরল)
gcc -fno-pic -c probe.c -o probe_nopic.o
objdump -d probe_nopic.o

# PIC — আধুনিক shared-library-উপযোগী
gcc -fPIC -c probe.c -o probe_pic.o
objdump -d probe_pic.o

সাধারণ ফলাফল (-fno-pic):

0000000000000000 <get_global>:
   0:	8b 05 00 00 00 00    	mov    eax,DWORD PTR [rip+0x0]     # relocation বসবে এখানে, absolute হিসেবে

সাধারণ ফলাফল (-fPIC):

0000000000000000 <get_global>:
   0:	8b 05 00 00 00 00    	mov    eax,DWORD PTR [rip+0x0]     # relocation, কিন্তু RIP-relative

দুটোই দেখতে প্রায় একই — পার্থক্যটা byte-এ না, relocation type-এ লুকানো:

objdump -r probe_nopic.o
# RELOCATION RECORDS FOR [.text]:
# OFFSET           TYPE              VALUE
# 0000000000000002 R_X86_64_32S      global_var        ← absolute, 32-bit sign-extended

objdump -r probe_pic.o
# RELOCATION RECORDS FOR [.text]:
# OFFSET           TYPE              VALUE
# 0000000000000002 R_X86_64_PC32     global_var        ← PC-relative, 32-bit

R_X86_64_32S বনাম R_X86_64_PC32 — এই একটা relocation-type-ই পুরো PIC গল্পের মূল সুইচ। _32S লিংকারকে বলে “এই স্লটে symbol-এর absolute ঠিকানা বসাও”; PC32 বলে “এই স্লটে (symbol ঠিকানা − এই instruction-এর ঠিকানা) বসাও” — একটা distance, absolute value না।

-fno-pic-এ shared library বানানোর চেষ্টা করলে লিংকার আপত্তি করে:

$ gcc -shared -fno-pic -o libbad.so probe_nopic.o
/usr/bin/ld: probe_nopic.o: relocation R_X86_64_32S against symbol
`global_var' can not be used when making a shared object; recompile with -fPIC

এই error message-টাই এই পুরো লেসনের সারমর্ম এক লাইনে।

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

একই C কোড, শুধু -fPIC পতাকা বদলালে global variable access সম্পূর্ণ ভিন্ন instruction sequence তৈরি করে — absolute addressing থেকে RIP-relative-এ।

EXPERIMENT

ASLR সরাসরি দেখা

Linux· ৫ মিনিট
for i in 1 2 3; do
  ./a.out & sleep 0.1
  grep 'libc.so' /proc/$!/maps | head -1
  wait
done

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

7f3a2c000000-7f3a2c025000 r--p ... libc.so.6
7f8e91800000-7f8e91825000 r--p ... libc.so.6
7fc105c00000-7fc105c25000 r--p ... libc.so.6

তিনটা রান, তিনটা ভিন্ন ঠিকানা — libc-এর কোড bytes হুবহু একই, শুধু লোড-address ভিন্ন। এটা কাজ করে শুধুমাত্র কারণ libc.so.6-এর প্রতিটা internal reference RIP-relative, আর প্রতিটা external reference GOT/PLT-এর মধ্য দিয়ে — কোথাও কোনো absolute ঠিকানা hardcode করা নেই যা ভুল হয়ে যেতে পারত।

setarch -R দিয়ে ASLR বন্ধ করে পার্থক্য দেখুন:

for i in 1 2 3; do
  setarch $(uname -m) -R ./a.out & sleep 0.1
  grep 'libc.so' /proc/$!/maps | head -1
  wait
done
7ffff7c00000-7ffff7c25000 r--p ... libc.so.6
7ffff7c00000-7ffff7c25000 r--p ... libc.so.6
7ffff7c00000-7ffff7c25000 r--p ... libc.so.6

তিনবারই অভিন্ন ঠিকানা — randomization বন্ধ, কিন্তু PIC কোডটা এখনও ঠিক একইভাবে কাজ করছে (এটা প্রমাণ করে PIC আর ASLR আলাদা জিনিস — PIC ASLR-কে সম্ভব করে, বাধ্য করে না)।

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

একই shared library প্রতিবার প্রোগ্রাম রান করলে আলাদা ঠিকানায় লোড হয় — আর PIC ছাড়া এটা সম্ভবই হতো না।

নিজে বানান

BUILD IT

PIE Detector

Python বা Bash · ●●○○○
  1. ELF header-এর e_type field পড়ুন — ET_EXEC (2) নাকি ET_DYN (3)
  2. ET_DYN হলে .interp section আছে কি না দেখুন — থাকলে এটা PIE executable, না থাকলে plain shared library
  3. একটা ডিরেক্টরির সব ELF বাইনারি স্ক্যান করে PIE/non-PIE/shared-lib হিসেবে শ্রেণীবদ্ধ করুন
  4. হাতে-লেখা ফলাফল readelf -h এবং file কমান্ডের আউটপুটের সাথে মিলিয়ে যাচাই করুন

মূল ধারণা: ELF header-এর e_type ফিল্ড ET_EXEC (পুরনো non-PIE executable), ET_DYN (shared library অথবা PIE executable — দুটোই একই type ব্যবহার করে), বা ET_REL (object file, লিংক হয়নি) হতে পারে। ET_DYN-এর মধ্যে PIE বনাম plain .so আলাদা করতে .interp section বা program header-এর PT_INTERP entry খুঁজতে হয় — সেটা থাকলে এটা নিজে থেকে চলার যোগ্য একটা executable, নাহলে একটা library যাকে অন্য প্রোগ্রাম লোড করবে।

import struct
import sys

ET_TYPES = {1: "ET_REL (object)", 2: "ET_EXEC (non-PIE)", 3: "ET_DYN (PIE বা shared lib)"}

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

    if data[:4] != b"\x7fELF":
        return f"{path}: ELF না"

    ei_class = data[4]              # 1 = 32-bit, 2 = 64-bit
    is_64 = ei_class == 2

    if is_64:
        e_type = struct.unpack_from("<H", data, 16)[0]
        e_phoff = struct.unpack_from("<Q", data, 32)[0]
        e_phentsize = struct.unpack_from("<H", data, 54)[0]
        e_phnum = struct.unpack_from("<H", data, 56)[0]
    else:
        e_type = struct.unpack_from("<H", data, 16)[0]
        e_phoff = struct.unpack_from("<I", data, 28)[0]
        e_phentsize = struct.unpack_from("<H", data, 42)[0]
        e_phnum = struct.unpack_from("<H", data, 44)[0]

    type_name = ET_TYPES.get(e_type, f"অজানা ({e_type})")

    has_interp = False
    for i in range(e_phnum):
        off = e_phoff + i * e_phentsize
        p_type = struct.unpack_from("<I", data, off)[0]
        if p_type == 3:            # PT_INTERP
            has_interp = True
            break

    if e_type == 3:
        kind = "PIE executable" if has_interp else "shared library (.so)"
    elif e_type == 2:
        kind = "non-PIE executable (পুরনো স্টাইল)"
    else:
        kind = type_name

    return f"{path}: {type_name}{kind}"


if __name__ == "__main__":
    for path in sys.argv[1:]:
        print(classify(path))
$ python3 pie_detector.py /bin/ls /lib/x86_64-linux-gnu/libc.so.6 ./old_style
/bin/ls: ET_DYN (PIE বা shared lib) → PIE executable
/lib/x86_64-linux-gnu/libc.so.6: ET_DYN (PIE বা shared lib) → shared library (.so)
./old_style: ET_EXEC (non-PIE) → non-PIE executable (পুরনো স্টাইল)

নিজে বাড়ান:

  1. readelf -h আর readelf -l (program headers) দিয়ে হাতে ভেরিফাই করুন PT_INTERP সত্যিই ধরা পড়েছে কি না
  2. /usr/bin-এর সব বাইনারি স্ক্যান করে কতগুলো এখনো non-PIE তা গুনুন — আধুনিক distro-তে প্রায় শূন্য পাবেন
  3. readelf -d দিয়ে DT_FLAGS_1-এর DF_1_PIE bit চেক করে আরেকটা স্বাধীন উপায়ে PIE শনাক্ত করুন, দুই পদ্ধতির ফলাফল মেলান

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

PIC/PIE যেখানে সিদ্ধান্ত নেয়

Ubuntu/Fedora-এর ডিফল্ট hardening। ২০১৭ সাল থেকে (gcc 6+) সব major distro ডিফল্টভাবে -fPIE -pie দিয়ে কম্পাইল করে — প্রতিটা সাধারণ executable-ই এখন ASLR পায়, শুধু shared library না।

Android-এর সব app। Android 5.0 (Lollipop) থেকে non-PIE executable রান করতেই দেয় না — নিরাপত্তা নীতির অংশ হিসেবে PIE বাধ্যতামূলক।

iOS ASLR + PIE। iOS-এর প্রতিটা app PIE হিসেবে সাইন হতে বাধ্য (App Store review নীতি) — জেলব্রেক-বিরোধী প্রতিরক্ষার একটা স্তর।

LD_PRELOAD আর symbol interposition। GOT-ভিত্তিক indirection থাকার কারণেই LD_PRELOAD=./myshim.so ./program কাজ করে — dynamic linker একটা প্রিলোড করা লাইব্রেরির symbol-কে অগ্রাধিকার দিয়ে GOT-এ বসায়, ফলে original function কল আসলে shim-এ যায়। strace-এর মতো টুল, memory-leak detector (valgrind না হলেও কিছু profiler), আর টেস্ট mocking framework এই কৌশলই ব্যবহার করে।

Retpoline mitigation (Spectre v2)। ২০১৮-এ Spectre branch-target- injection আক্রমণ ঠেকাতে কম্পাইলার call/jmp indirect branch-কে (ঠিক PLT stub-এর মতোই দেখতে) একটা বিশেষ “retpoline” প্যাটার্নে রূপান্তর করত, যাতে CPU-র branch predictor speculative execution দিয়ে গোপন তথ্য leak করতে না পারে। PLT-এর গঠন বোঝা থাকলে retpoline কোড পড়া অনেক সহজ হয়।

Container image size। Static linking (কোনো PIC/PLT/GOT ছাড়াই, সব একটা বাইনারিতে বান্ডিল) বনাম dynamic linking-এর trade-off আধুনিক container ecosystem-এ (Docker, distroless image) সরাসরি প্রাসঙ্গিক — static বাইনারি বড়, কিন্তু dependency-hell এড়ায় এবং GOT/PLT overhead নেই।

JIT compiler-এর self-modifying code। V8 (Chrome/Node.js) বা JVM-এর JIT-generated কোড runtime-এ তৈরি হয়, নিজের ঠিকানা আগে থেকে জানে না — এই কোডও কার্যত position-independent নীতিতে চলে, যদিও কোনো traditional GOT/PLT নেই, বরং নিজস্ব internal jump table।

Performance cost বাস্তবে কতটা। PIC-এর প্রতিটা external access এ একটা বাড়তি memory indirection যোগ করে — ঐতিহাসিকভাবে (৩২-বিট x86-এ, যেখানে RIP-relative addressing ছিলই না, একটা বাড়তি register আর “call-pop” trick লাগত base ঠিকানা বের করতে) এটা ৫-১০% পর্যন্ত overhead তৈরি করতে পারত। x86-64-এ RIP-relative addressing নেটিভ support থাকায় এই খরচ প্রায় মুছে গেছে — এখন PIC আর non-PIC-এর মধ্যে performance পার্থক্য সাধারণত পরিমাপযোগ্য নয়।

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

“PIC আর PIE একই জিনিস।”

কাছাকাছি সম্পর্কিত, কিন্তু আলাদা স্তরের ধারণা।

PIC (Position-Independent Code) — একটা কম্পাইলেশন বৈশিষ্ট্য: -fPIC দিয়ে কম্পাইল করা কোড, যেখানে internal reference-গুলো RIP-relative। যেকোনো .o, .so, বা executable-এর জন্য প্রযোজ্য।

PIE (Position-Independent Executable) — একটা আউটপুট বিভাগ: -fPIE -pie দিয়ে লিংক করা একটা সম্পূর্ণ executable, যেটা নিজেও একটা .so-র মতো যেকোনো ঠিকানায় লোড হতে পারে (ET_DYN + PT_INTERP)।

সব PIE কোড PIC (একটা PIE-এর ভেতরের কোডও RIP-relative), কিন্তু সব PIC .so PIE না (একটা .so-তে entry point বা PT_INTERP নেই, সেটা নিজে থেকে চলে না, অন্য কেউ তাকে লোড করে)।

“PIC-এ প্রতিটা reference GOT/PLT-এর মধ্য দিয়ে যায়।”

এই লেসনের example অংশটাই এর বিপরীত দেখায়। শুধু বাইরের (অন্য .so-তে সংজ্ঞায়িত বা preemptable) symbol GOT/PLT ব্যবহার করে। একই .so-র ভেতরের static variable বা internal function-এর জন্য compiler সরাসরি lea rax, [rip+offset] ব্যবহার করে — কোনো indirection, কোনো GOT entry, কোনো relocation-lookup-at-load-time লাগে না।

Linker-এর একটা optimization (-Bsymbolic বা -fvisibility=hidden) এমনকি লাইব্রেরির নিজের exported symbol-কেও direct করে দিতে পারে, যদি জানা যায় সেই symbol অন্য কোনো লাইব্রেরি দিয়ে override হবে না।

“Static executable-এর জন্য PIC-এর কোনো মানে নেই।”

আংশিক সত্য, কিন্তু বিভ্রান্তিকর। একটা সম্পূর্ণ statically-linked, non-PIE executable-এর জন্য PIC প্রযোজ্য না — পুরো প্রোগ্রামটাই একটা fixed base address-এ লোড হয়, কোনো shared library ইস্যু নেই।

কিন্তু আজকাল “static executable” প্রায়ই মানে statically-linked কিন্তু এখনও PIE — যেমন Go বাইনারি বা Rust-এর musl target দিয়ে বানানো বাইনারি। এগুলোতে কোনো বাহ্যিক .so dependency নেই (তাই কোনো GOT/PLT external-resolve দরকার নেই), তবু পুরো বাইনারিটা নিজেই একটা random ঠিকানায় লোড হয় (ASLR-এর সুবিধা পেতে) — অর্থাৎ কোডটা তখনও position-independent, শুধু bare-metal PIC-এর মতো কোনো dynamic resolution ছাড়াই।

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

1

lea rax, [rip+0x2ec8] (internal static variable) আর mov rax, [rip+0x2ec4] (external variable) — দুটোই RIP-relative addressing ব্যবহার করে। তাহলে পার্থক্যটা কোথায়, আর কেন এই পার্থক্য দরকার?

যুক্তি

পার্থক্য instruction-এর ধরনে — lea বনাম mov

lea rax, [rip+offset] — “Load Effective Address”: [rip+offset] গণনা করে, কোনো memory access ছাড়াই, সরাসরি সেই গণনা করা মানটা rax-এ বসায়। ফলাফল: rax = target variable-এর ঠিকানা

mov rax, [rip+offset] — “Move”: [rip+offset]-কে একটা ঠিকানা হিসেবে ব্যবহার করে সেখান থেকে read করে, সেই read করা মানটা rax-এ বসায়। এখানে [rip+offset] টা আসলে GOT-এর একটা স্লট — তার ভেতরের মান-ই হলো target variable-এর ঠিকানা।

কেন দরকার: static variable-এর ঠিকানা compile/link-time-এ জানা (একই .so-র ভেতরে, কেউ override করতে পারে না) — তাই সরাসরি distance গণনা যথেষ্ট, lea

External variable-এর প্রকৃত সংজ্ঞা অন্য কোনো .so-তে থাকতে পারে, এমনকি LD_PRELOAD দিয়ে interpose হতে পারে — কম্পাইল-টাইমে জানা অসম্ভব কোন .so-তে, কত দূরে। তাই একটা extra level of indirection দরকার: mov দিয়ে GOT থেকে “dynamic linker যা resolve করেছে” সেই মানটা পড়া।

সংক্ষেপে: lea = “আমি জানি এটা কোথায়, সরাসরি গণনা করি।” mov [GOT] = “আমি জানি না, dynamic linker-কে জিজ্ঞেস করি (এবং তার উত্তরটা GOT-এ ক্যাশ করা আছে)।”

2

একটা প্রোগ্রামে foo() ফাংশনটা একই session-এ ৫০,০০০ বার call হয়, আর কখনো call না হওয়া একটা bar() ফাংশনও শেয়ার্ড লাইব্রেরিতে আছে। Lazy binding-এর কারণে এই দুটোর dynamic-linker খরচে কী পার্থক্য হবে?

প্রয়োগ

bar()-এর GOT entry কখনো resolve হবে না — dynamic linker-এর resolver একবারও ডাকা হবে না, কারণ resolve করা হয় শুধু প্রথম call-এর সময়, আর bar() কখনো call-ই হয় না। যদি লাইব্রেরিতে হাজার হাজার এক্সপোর্টেড ফাংশন থাকে কিন্তু প্রোগ্রামটা সামান্য কয়েকটাই ব্যবহার করে (যা সাধারণ — যেমন libc-এর হাজার হাজার ফাংশনের মধ্যে বেশিরভাগ প্রোগ্রাম মাত্র কয়েক ডজন ব্যবহার করে), এই “কখনো resolve না হওয়া” lazy binding-এর মূল সাশ্রয়।

foo()-এর জন্য: প্রথম call-এ resolver চলবে — symbol lookup (হ্যাশ টেবিলে foo নাম খোঁজা, সঠিক লাইব্রেরি বাছা, symbol versioning চেক করা) — এটা তুলনামূলক ব্যয়বহুল, কয়েকশ থেকে কয়েক হাজার cycle। এই একবারের খরচের পর GOT entry patch হয়ে যায়।

পরের ৪৯,৯৯৯ বার: শুধু PLT stub-এর jmp [rip+got_offset] — একটা indirect jump, কয়েক cycle, resolver স্পর্শও হয় না।

মোট প্রভাব: foo()-এর amortised খরচ প্রায় সম্পূর্ণ eager-binding (যেখানে সবকিছু startup-এই resolve হয়) এর সমান হয়ে যায়, কারণ ৫০,০০০ বারের মধ্যে মাত্র ১ বারের বাড়তি খরচ নগণ্য হয়ে যায়। কিন্তু bar()-এর জন্য lazy binding সম্পূর্ণ একটা resolve বাঁচিয়ে দিল, যেটা eager (LD_BIND_NOW=1) হলে startup-এই খরচ হতো, ব্যবহার না হলেও।

3

gcc -shared -fno-pic -o libbad.so probe.o চালালে লিংকার একটা error দেয় — “relocation R_X86_64_32S … can not be used when making a shared object”। এই error-টা কেন আসে, শুধু “নিয়ম ভঙ্গ” বলে না, বরং কী কংক্রিট সমস্যা এড়াতে এই নিয়ম আছে তা ব্যাখ্যা করুন।

যুক্তি

R_X86_64_32S একটা absolute relocation — এটা লিংকারকে বলে “এই instruction-এর এই স্লটে symbol-এর চূড়ান্ত, absolute ঠিকানা বসিয়ে দাও, একটা fixed 32-bit sign-extended সংখ্যা হিসেবে।”

একটা normal executable-এর জন্য এটা ঠিক আছে — executable-টার নিজের একটা fixed load-base থাকে (ঐতিহাসিকভাবে non-PIE-তে), তাই লিংকার জানে সব variable-এর absolute ঠিকানা এবং সেটা সরাসরি বসিয়ে দিতে পারে।

কিন্তু একটা shared library কোনো fixed load-base প্রতিশ্রুতি দিতে পারে না — সেটা যেকোনো প্রসেসে যেকোনো ঠিকানায় লোড হতে পারে, এবং একাধিক প্রসেসে ভিন্ন ভিন্ন ঠিকানায়ও (ASLR, বা একাধিক লাইব্রেরির মধ্যে address-space সংঘর্ষ এড়াতে)। যদি লিংকার একটা absolute ঠিকানা .so-র bytes-এর ভেতরে স্থায়ীভাবে বসিয়ে দিত (build-time-এ), সেই ঠিকানাটা শুধুমাত্র একটা নির্দিষ্ট load-base-এর জন্যই সঠিক হতো — অন্য যেকোনো load-base-এ ভুল ঠিকানা নির্দেশ করত, প্রোগ্রাম crash করত বা আরও খারাপ, ভুল মেমরি অ্যাক্সেস করত।

লিংকার এই পুরো সমস্যাটা build-time-এই ধরে ফেলে বলে দেয়: “তুমি এমন একটা relocation চাইছ যেটা shared library-তে অর্থহীন — -fPIC দিয়ে আবার কম্পাইল করো, যাতে R_X86_64_PC32 (PC-relative, distance-ভিত্তিক, load-base-নির্বিশেষে বৈধ) জেনারেট হয়।”

সংক্ষেপে: এই error না থাকলে, একটা shared library ঠিক তখনই ভাঙত যখন সেটা তার “প্রত্যাশিত” ঠিকানা ছাড়া অন্য কোথাও লোড হতো — যা shared-library ecosystem-এ প্রায় নিশ্চিতভাবেই ঘটবে। লিংকার সেই future crash-টা compile-time-এই আটকে দেয়।

4

একটা embedded system-এ শুধু একটাই প্রোগ্রাম চলে, কোনো ASLR নেই (সিকিউরিটি প্রয়োজনীয়তা নেই ধরে নিন), কোনো shared library নেই — সব কিছু static-linked। এই পরিস্থিতিতে PIC ব্যবহার করা কি এখনো বুদ্ধিমানের কাজ?

ডিজাইন

সাধারণত না — এখানে PIC-এর কোনো সুবিধা নেই, শুধু (সামান্য) খরচ।

PIC দুইটা সমস্যার সমাধান করে: (১) একাধিক প্রোগ্রাম/লাইব্রেরির মধ্যে address-space শেয়ারিং, আর (২) ASLR-এর জন্য randomizable লোড-ঠিকানা।

এই embedded পরিস্থিতিতে কোনোটাই প্রযোজ্য না:

  • কোনো shared library নেই → address-space-শেয়ারিং সমস্যাটাই নেই
  • ASLR অপ্রয়োজনীয় (single-purpose device, security threat model ভিন্ন, প্রায়ই memory protection ইউনিটই নেই) → randomization-এর দরকার নেই
  • একটাই প্রোগ্রাম, একটা fixed flash/RAM layout → compile-time-এই প্রতিটা ঠিকানা নির্দিষ্ট করে দেওয়া যায়, নির্ভরযোগ্যভাবে

এখানে non-PIC (-fno-pic, ডিফল্ট many embedded toolchain-এ) থেকে বাস্তব সুবিধা আছে: absolute addressing-এ RIP-relative-এর বাড়তি indirection (external symbol-এর ক্ষেত্রে GOT lookup) লাগে না, আর কিছু আর্কিটেকচারে (বিশেষত ছোট, সরল microcontroller ISA-তে যেখানে PC-relative addressing-এর reach সীমিত) non-PIC কোড ছোট আর দ্রুত হতে পারে।

সাধারণ নীতি: PIC-এর মূল্য তার সমস্যার প্রাসঙ্গিকতার উপর নির্ভর করে — shared library আর ASLR ছাড়া, PIC একটা সমাধান যার সমস্যাটাই নেই। এই কারণেই বহু bare-metal/RTOS টুলচেইন (ARM Cortex-M-এর জন্য) ডিফল্টভাবে non-PIC কম্পাইল করে, যেখানে Linux distro টুলচেইন ডিফল্টভাবে PIC/PIE করে — দুটোই যুক্তিসঙ্গত, কারণ তাদের সমস্যা ভিন্ন।

একটা ব্যতিক্রম: bootloader-এর মতো কোড, যেটা RAM-এর কোন ঠিকানায় loaded হবে তা runtime পর্যন্ত নিশ্চিত না (bootloader নিজেই সেটা ঠিক করে) — সেখানে PIC (বা তার cousin, position-independent “relocatable” কোড) এখনো দরকারি, ASLR না থাকা সত্ত্বেও।

5

GOT আর PLT-এর মধ্যে মূল দায়িত্ব-বিভাজন এক বাক্যে লিখুন — কোনটা কী সংরক্ষণ করে, আর কেন দুটো আলাদা টেবিল দরকার (একটা দিয়ে কাজ চলে না কেন)?

স্মরণ

GOT (Global Offset Table) বাইরের প্রতিটা symbol-এর (data বা function) resolved absolute address সংরক্ষণ করে — dynamic linker load-time বা lazy-resolve-time-এ এটা ভরে দেয়।

PLT (Procedure Linkage Table) শুধু function-এর জন্য, একটা ছোট code stub হিসেবে বসে থাকে যেটা GOT-এর মধ্য দিয়ে জাম্প করে — আর প্রথমবার call হলে সেই GOT entry এখনো unresolved থাকলে dynamic linker-এর resolver-কে ডাকার যুক্তিটাও ধরে রাখে (lazy binding)।

কেন দুটো আলাদা টেবিল দরকার: ডেটার জন্য “lazy” concept-টাই অর্থহীন — একটা variable read করার আগে “এখনও resolve হয়নি, resolve করো” বলার মতো কোনো code path নেই, dynamic linker সব data symbol load-time-এই resolve করে ফেলে (তাই শুধু GOT, কোনো “PLT-এর মতো” স্তর লাগে না)। কিন্তু function call-এর ক্ষেত্রে lazy resolution একটা বাস্তব performance সুবিধা দেয় (startup-এ অপ্রয়োজনীয় resolve এড়ানো) — সেই “প্রথমবার resolver ডাকো, GOT patch করো, পরের বার সরাসরি জাম্প করো” যুক্তিটা রাখার জন্যই function call-এ একটা বাড়তি code stub (PLT) দরকার, শুধু data access-এ যথেষ্ট না এমন GOT-এর একটা সাধারণ load-এর তুলনায়।

এরপর কী

পরের ধাপ — এই কোডটা আসলে কোন ফাইলে থাকে

এই লেসনে আমরা দেখেছি কীভাবে কোড RIP-relative addressing, GOT আর PLT ব্যবহার করে অবস্থান-নিরপেক্ষ হয়। কিন্তু এই সব — .text-এর instruction, GOT-এর entry, .rela.plt-এর relocation তালিকা, PT_INTERP প্রোগ্রাম হেডার — এগুলো আসলে একটা ফাইলের ভেতরে কোথাও সংরক্ষিত থাকে, একটা নির্দিষ্ট বাইট-বিন্যাসে।

পরের লেসনে আমরা সেই বিন্যাসটা খুলে দেখব — ELF (Executable and Linkable Format)। এই লেসনের readelf -d, readelf -r কমান্ডগুলো যা দেখাল, তার প্রতিটা byte কোথায় বসে, কীভাবে organized, আর কেন executable/shared-library/object-file — তিনটাই একই ফরম্যাট শেয়ার করে — সেটাই পরের বিষয়।

আরও পড়ুন

  • How To Write Shared Libraries — Ulrich Drepper · GOT/PLT আর symbol interposition-এর সবচেয়ে বিস্তারিত ও প্রামাণ্য আলোচনা
  • System V Application Binary Interface — AMD64 Architecture Processor Supplement · PLT-এর ঠিক mechanical বিবরণ, Chapter 3 (Dynamic Linking)
  • Linkers and Loaders — John R. Levine · Position-independent code আর relocation-এর ইতিহাস, Chapter 8