Stack Frame ও Function Call — call/ret-এর ভেতরের যন্ত্রপাতি
Stack Frames and Function Calls
Stack একটা memory-অঞ্চল যেটা নিচের দিকে বাড়ে — call return address push করে জাম্প করে, prologue/epilogue frame স্থাপন ও ভাঙে, আর ঠিক এই একই কাঠামো buffer overflow-এর মতো একটা ক্লাসিক দুর্বলতার জন্মস্থান।
আগে এটা বুঝি
গত লেসনে বারবার দুইটা instruction দেখেছেন যেন এক ধরনের আচার-অনুষ্ঠান — push %rbp; mov %rsp, %rbp ফাংশনের শুরুতে, pop %rbp; ret শেষে। ব্যবহার করেছি, কিন্তু প্রশ্ন করিনি: কেন ঠিক এই দুইটা, এই ক্রমে? আর ret-এর ব্যাপারটা আরও রহস্যময় — কোনো operand ছাড়াই এটা কীভাবে জানে ঠিক কোন ঠিকানায় ফিরে যেতে হবে? প্রতিটা call কি কোথাও লিখে রাখে “আমি এখান থেকে এসেছি”?
উত্তর হ্যাঁ — আর সেই “কোথাও”টাই এই লেসনের মূল বিষয়: stack। এই লেসনে আমরা stack-কে নিছক একটা abstract ধারণা না, বরং memory-র একটা সুনির্দিষ্ট অঞ্চল হিসেবে দেখব, যার একটা পাল্টা-স্বজ্ঞাত (counter-intuitive) কিন্তু সুনির্দিষ্ট কারণে-চালিত বৃদ্ধির দিক আছে। দেখব call ঠিক কী দুইটা কাজ একসাথে করে, prologue-এর প্রতিটা instruction কেন সেখানে বাধ্যতামূলক, আর সবশেষে — কীভাবে এই একই কাঠামোর একটা সরল দুর্বলতা কম্পিউটিং ইতিহাসের সবচেয়ে বিখ্যাত নিরাপত্তা-বাগ শ্রেণির জন্ম দিয়েছে।
মূল ধারণা
Stack — memory-র একটা অঞ্চল যা “উল্টো দিকে” বাড়ে
Stack কোনো বিশেষ hardware কাঠামো না — এটা RAM-এরই একটা সাধারণ, ধারাবাহিক অঞ্চল, যেটা প্রচলিতভাবে address space-এর উঁচু প্রান্তের কাছে বরাদ্দ করা হয় (গত মডিউলের registers-pc-flags লেসনে এই ছবিটা প্রথম দেখেছেন)। rsp (stack pointer) register সবসময় এই অঞ্চলের বর্তমান “top” — সবচেয়ে সাম্প্রতিক যোগ করা byte-এর ঠিকানা — ধরে রাখে।
এখানেই প্রথম পাল্টা-স্বজ্ঞাত বিষয়টা আসে। বাস্তব জীবনে একটা প্লেটের স্তূপে (stack of plates) নতুন প্লেট রাখলে স্তূপ উপরের দিকে বাড়ে — উচ্চতা বাড়ে। কিন্তু x86-64 (এবং বেশিরভাগ আধুনিক ISA-তে) memory-র stack ঠিক উল্টো দিকে বাড়ে — নিচের দিকে, অর্থাৎ প্রতিটা নতুন push-এ ঠিকানার সাংখ্যিক মান কমে।
উচ্চ ঠিকানা (higher address)
0x7fff_ffff_f000 ┌─────────────────────┐ ← stack-এর সূচনা-বিন্দু
│ (এখনও ব্যবহৃত হয়নি) │
├─────────────────────┤
│ push হওয়া data-১ │
├─────────────────────┤
│ push হওয়া data-২ │
0x7fff_ffff_efe0 ├─────────────────────┤ ← rsp এখন এখানে (current top)
│ (এখনও অব্যবহৃত) │
└─────────────────────┘
নিম্ন ঠিকানা (lower address)
push করলে rsp কমে (নিচে নামে) ↓
pop করলে rsp বাড়ে (উপরে ওঠে) ↑কেন এই দিক? এটা কোনো hardware-বাধ্যতা না — কিন্তু ঐতিহাসিক আর ব্যবহারিক একটা যুক্তি আছে যা আজও প্রাসঙ্গিক। একটা প্রোগ্রামের address space-এ code/static-data সাধারণত নিম্ন ঠিকানা থেকে শুরু হয়ে উপরের দিকে বাড়ে (আর dynamically-allocated heap-ও একই দিকে বাড়ে, code/data-র ঠিক পরে)। Stack যদি এর বিপরীত দিক থেকে — সবচেয়ে উঁচু ঠিকানা থেকে — শুরু করে নিচের দিকে বাড়ে, তাহলে দুইটা অঞ্চল একে অপরের দিকে বাড়ে, মাঝখানে যতটা সম্ভব বেশি জায়গা খালি রেখে — কোনো একটা অঞ্চলের জন্য আগে থেকে নির্দিষ্ট আকার বরাদ্দ না করেই। দুইটা যদি একই দিকে বাড়ত, একটাকে অন্যটার আকার সম্পর্কে আগে থেকেই একটা কঠোর সীমা জানতে হতো।
ভেতরে কী ঘটছে
call-এর প্রকৃত mechanics — push আর jump, একই instruction-এ
call target একটা single instruction, কিন্তু ভেতরে দুইটা কাজ atomically (interrupt-অবিভাজ্যভাবে) করে:
call target ≡ push (call-এর ঠিক পরের instruction-এর ঠিকানা)
jmp targetপ্রথম ধাপ: বর্তমান instruction-এর ঠিক পরের instruction-এর ঠিকানা (এটাই “return address” — যেখানে callee ফিরে এলে execution চালিয়ে যেতে হবে) stack-এ push করা হয় — ঠিক আগের সেকশনের push-এর মতোই, rsp ৮ কমে, তারপর সেই ঠিকানা (%rsp)-এ লেখা হয়।
দ্বিতীয় ধাপ: PC (rip)-এ target-এর ঠিকানা বসিয়ে দেওয়া হয় — control সরাসরি callee-র প্রথম instruction-এ চলে যায়।
কেন এই দুইটা এক instruction-এ একসাথে বাঁধা? যদি এগুলো আলাদা instruction হতো (একটা push, তারপর একটা আলাদা jump), তাহলে দুইটার মাঝখানে কোনো interrupt/exception ঘটলে একটা অসংগত অবস্থা তৈরি হতে পারত — return address push হয়েছে কিন্তু জাম্প হয়নি, বা উল্টো। একটা একক atomic instruction হিসেবে call এই ঝুঁকি সম্পূর্ণ দূর করে — হয় পুরোটা ঘটবে, নাহলে কিছুই ঘটবে না।
ret-এর mechanics — pop, কিন্তু rip-তে
ret-এর কোনো operand লাগে না, কারণ এটা সবসময় ঠিক একই জায়গা থেকে পড়ে — stack-এর বর্তমান top:
ret ≡ pop %rip (ধারণাগতভাবে — rip সরাসরি operand হিসেবে address করা যায় না, কিন্তু এটাই semantics)অর্থাৎ, (%rsp)-এ থাকা মান (যেটা কোনো এক call-এর push করা return address, ধরে নিয়ে stack ঠিকমতো balanced আছে) rip-এ কপি হয়, rsp ৮ বাড়ে। পরবর্তী fetch ঠিক সেই ঠিকানা থেকে শুরু হয় — যেন কিছুই ঘটেনি, শুধু execution সেই call-এর পরের instruction থেকে চলতে থাকে।
Prologue — প্রতিটা instruction কেন সেখানে আছে
স্ট্যান্ডার্ড prologue তিনটা instruction:
push %rbp ; (১) caller-এর frame pointer বাঁচাও
mov %rsp, %rbp ; (২) নতুন frame pointer স্থাপন করো
sub $N, %rsp ; (৩) local variable-এর জন্য N byte জায়গা রাখোপ্রতিটা এখানে থাকার একটা নির্দিষ্ট, derivable কারণ আছে — মুখস্থ করার কোনো দরকার নেই যদি কারণটা বুঝে ফেলেন:
(১) push %rbp। গত লেসনে শিখেছেন rbp একটা callee-saved register — অর্থাৎ, এই ফাংশন যদি rbp ব্যবহার করতে চায়, তাকে ফেরার আগে caller-এর পুরোনো মান ফিরিয়ে দিতে হবে। এই ফাংশন ঠিক পরের instruction-এই rbp-তে নতুন মান বসাতে যাচ্ছে (ধাপ ২), তাই ব্যবহারের আগেই পুরোনো মান stack-এ বাঁচিয়ে রাখা — ঠিক ABI-র callee-saved নিয়ম মেনে।
(২) mov %rsp, %rbp। এখন প্রশ্ন — কেন rbp-কে “frame pointer” হিসেবে আলাদা করে ব্যবহার করা, rsp থাকতেই? কারণ ফাংশনের শরীরে (body-তে) আরও push/pop/call ঘটতে পারে, যার প্রতিটাই rsp-কে নড়াচড়া করাবে। যদি local variable-গুলোকে সরাসরি rsp-এর সাপেক্ষে address করা হতো, প্রতিটা push/pop-এর পর সেই offset বদলে যেত — জটিল আর bug-প্রবণ। rbp-কে এই মুহূর্তের rsp-এ ফিক্স করে দিলে, এটা ফাংশনের বাকি অংশ জুড়ে স্থির থাকে (যতক্ষণ ফাংশন নিজে rbp না বদলায়) — একটা নির্ভরযোগ্য, অপরিবর্তনীয় রেফারেন্স-বিন্দু, যার সাপেক্ষে সব local variable আর argument ঠিকানা করা যায়।
(৩) sub $N, %rsp। local variable-গুলোর জন্য প্রকৃত জায়গা “দখল” করা দরকার — নাহলে পরবর্তী কোনো call বা push সেই একই মেমরি অঞ্চল ওভাররাইট করে দিতে পারে। rsp-কে N byte কমিয়ে দেওয়া মানে — “এই N byte এখন এই ফাংশনের local variable-এর জন্য সংরক্ষিত, আর কেউ এটা স্পর্শ করবে না যতক্ষণ না আমি rsp আবার বাড়াই।”
Epilogue — prologue-এর ঠিক বিপরীত, বিপরীত ক্রমে
mov %rbp, %rsp ; (১) locals deallocate — rsp-কে ফিরিয়ে দাও যেখানে prologue-এর ধাপ ২-এর পরে ছিল
pop %rbp ; (২) caller-এর rbp ফিরিয়ে দাও
ret ; (৩) return address pop করে rip-এ বসাওলক্ষ্য করুন — এটা ঠিক prologue-এর তিনটা ধাপের বিপরীত ক্রমে বাতিলকরণ (LIFO — Last In, First Out, ঠিক stack-এর সংজ্ঞা অনুযায়ীই)। যা শেষে করা হয়েছিল (sub), তা প্রথমে undo হয় (mov %rbp, %rsp — locals deallocate); যা প্রথমে করা হয়েছিল (push %rbp), তা শেষে undo হয় (pop %rbp)।
x86-64-এ এই প্রথম দুইটা ধাপের জন্য একটা shortcut instruction আছে — leave:
leave ≡ mov %rbp, %rsp
pop %rbpতাই বাস্তব compiled কোডে প্রায়ই দেখবেন শুধু leave; ret — দুইটা instruction-এই সম্পূর্ণ epilogue।
| Prologue (frame স্থাপন) | Epilogue (frame ভাঙা) |
|---|---|
push %rbp (caller-র rbp বাঁচাও) | pop %rbp (rbp ফিরিয়ে দাও) — শেষে |
mov %rsp, %rbp (নতুন frame pointer) | mov %rbp, %rsp (locals মুক্ত করো) — প্রথমে |
sub $N, %rsp (locals-এর জায়গা) | (leave-এর প্রথম ধাপেই মিশে আছে) |
যখন N কম্পাইল-টাইমে জানা যায় না — dynamic stack allocation
এতক্ষণ sub $N, %rsp-এ N একটা compile-time constant ধরে নিয়েছি (কারণ কতগুলো local variable, কত বড় — এটা সাধারণত compile করার সময়ই জানা যায়)। কিন্তু C-তে variable-length array (VLA) বা alloca()-এর মতো ফাংশন ব্যবহার করলে, একটা local buffer-এর আকার runtime-এ নির্ধারিত হতে পারে:
void f(int n) {
int buf[n]; // ★ n একটা runtime value — আকার compile-time-এ অজানা
...
}এক্ষেত্রে prologue-এ একটা fixed constant-এর বদলে rsp-কে runtime-এ গণনা করা মান দিয়ে কমানো হয়:
f:
push %rbp
mov %rsp, %rbp
mov %edi, %eax # n
cltq # sign-extend eax → rax (৬৪-বিট আকার-গণনার জন্য)
shl $2, %rax # n * 4 (int-এর আকার) — কতটুকু জায়গা লাগবে তা runtime-এ গণনা
sub %rax, %rsp # rsp-কে runtime-নির্ধারিত পরিমাণ কমানো — এটাই dynamic allocation
...এখানে মূল ধারণাটা একই — rsp-কে কমিয়ে জায়গা “দখল” করা — শুধু কমানোর পরিমাণটা এবার একটা constant না, একটা runtime-এ গণনা করা register-মান। এই একই mechanism-ই alloca() লাইব্রেরি-ফাংশনের ভিত্তি (যদিও বাস্তবে এটা compiler-এর ভেতরেই inline হয়, আসল function call হয় না)।
উদাহরণ
একটা সম্পূর্ণ concrete ফাংশন — দুইটা local variable-সহ
long compute(long x, long y) {
long a = x + 1;
long b = y * 2;
return a + b;
}x86-64 (representative compiled output, argument সরাসরি register থেকে ব্যবহৃত হচ্ছে, spill ছাড়াই):
compute:
push %rbp # prologue (১): caller-র rbp বাঁচাও
mov %rsp, %rbp # prologue (২): নতুন frame pointer
sub $16, %rsp # prologue (৩): a, b-র জন্য ১৬ byte
lea 1(%rdi), %rax # rax = x + 1
mov %rax, -8(%rbp) # a = rax (a থাকে rbp-8-এ)
lea (%rsi,%rsi), %rax # rax = y + y = y*2
mov %rax, -16(%rbp) # b = rax (b থাকে rbp-16-এ)
mov -8(%rbp), %rax # rax = a
add -16(%rbp), %rax # rax += b — return value rax-এ
leave # epilogue: mov %rbp,%rsp; pop %rbp
ret # return address pop → ripসম্পূর্ণ stack frame diagram
compute চলাকালীন, prologue শেষ হওয়ার পর, ঠিক এই মুহূর্তে stack-এর ছবিটা এমন দেখায় (উচ্চ ঠিকানা উপরে, নিম্ন ঠিকানা নিচে — এই লেসনের প্রথম সেকশনের নিয়ম অনুযায়ী):
উচ্চ ঠিকানা
┌─────────────────────┐
rbp+16 │ (caller-র frame) │
├─────────────────────┤
rbp+8 │ return address │ ← call এই push করেছিল
├─────────────────────┤
rbp+0 (rbp) │ saved rbp │ ← prologue-এর push %rbp
├─────────────────────┤
rbp-8 │ a (x + 1) │ ← local variable ১
├─────────────────────┤
rbp-16 (rsp) │ b (y * 2) │ ← local variable ২, এখানেই rsp
└─────────────────────┘
নিম্ন ঠিকানাতিনটা জিনিস এই diagram থেকে সরাসরি পড়া যায়:
(১) rbp সবসময় ঠিক saved-rbp-এর ঠিকানায় বসে থাকে — (%rbp) মানেই saved rbp, 8(%rbp) মানেই return address। এটাই “স্থির রেফারেন্স-বিন্দু” হওয়ার প্রত্যক্ষ ফলাফল।
(২) সব local variable negative offset-এ, rbp-র নিচে (rbp-8, rbp-16, …) — কারণ stack নিচের দিকে বাড়ে, prologue-এর sub সেই দিকেই জায়গা তৈরি করেছে।
(৩) rsp ঠিক rbp-16-এ — মানে sub $16, %rsp সম্পূর্ণ ব্যবহৃত হয়েছে, ঠিক ২টা long (৮+৮=১৬ byte)-এর জন্য, কোনো অপচয় ছাড়া।
দুইটা frame একসাথে — nested call-এ stack কেমন দেখায়
compute নিজে যদি আরেকটা ফাংশন call করত, stack-এ একসাথে দুইটা frame পাশাপাশি (উপর-নিচ) বসে থাকবে — প্রতিটা তার নিজের push %rbp; mov %rsp, %rbp দিয়ে তৈরি একটা স্বতন্ত্র চেইন-লিংক। ধরা যাক compute একটা helper(long v) call করে:
long helper(long v) { return v * v; }
long compute(long x, long y) {
long a = x + 1;
long b = helper(y) * 2; // ★ এখন একটা nested call
return a + b;
}helper-এর prologue শেষ হওয়ার মুহূর্তে, stack-এ দুইটা frame — নিচেরটা (helper-এর, নতুন, ছোট ঠিকানায়) উপরেরটার (compute-এর) ঠিক “নিচে” বসেছে, কারণ stack নিচের দিকে বেড়েছে:
উচ্চ ঠিকানা
┌─────────────────────┐
│ (main-এর frame) │
├─────────────────────┤
│ compute-এর return │ ← main-এর call compute করেছে
│ address │
compute-এর rbp ├─────────────────────┤
│ saved rbp (main-র) │
├─────────────────────┤
│ a (compute-এর) │
├─────────────────────┤
│ b (compute-এর) │
├─────────────────────┤
│ helper-এর return │ ← compute-এর call helper করেছে
│ address │
helper-এর rbp ├─────────────────────┤
│ saved rbp │ ← এটাই compute-এর rbp! (chain-এর লিংক)
└─────────────────────┘
নিম্ন ঠিকানা (rsp এখানে, helper চলাকালীন)এখানেই “frame-pointer chain”-এর আসল ছবি — helper-এর saved-rbp হুবহু compute-এর rbp-র মান বহন করছে (কারণ helper-এর prologue-এর push %rbp ঠিক compute-এর তখনকার rbp-ই push করেছিল)। এই কারণেই একটা debugger helper-এ breakpoint বসিয়ে, শুধু saved-rbp অনুসরণ করে করে ((%rbp) পড়ে, তারপর সেই ঠিকানায় গিয়ে আবার (%rbp) পড়ে…) সম্পূর্ণ call chain (helper ← compute ← main) পুনর্গঠন করতে পারে, কোনো আলাদা “call log” ছাড়াই — realworld সেকশনে এটাই bt কমান্ডের ভিত্তি হিসেবে ফিরে আসবে।
নিজে চালিয়ে দেখুন
GDB দিয়ে live stack frame observe করুন
// frame_demo.c
long compute(long x, long y) {
long a = x + 1;
long b = y * 2;
return a + b;
}
int main() {
long r = compute(10, 20);
return (int)r;
}gcc -O0 -fno-stack-protector -g frame_demo.c -o frame_demo
gdb ./frame_demoGDB-এর ভেতরে:
(gdb) break compute
(gdb) run
(gdb) print $rsp
(gdb) print $rbp
(gdb) x/2xg $rsp ; top-এ কী আছে — return address দেখা যাবে
(gdb) disassemble
(gdb) stepi
(gdb) print $rsp ; push %rbp-এর পর — ৮ কমেছে
(gdb) stepi
(gdb) print $rbp ; mov %rsp,%rbp-এর পর — rsp-র সমান হয়ে গেছে
(gdb) stepi
(gdb) print $rsp ; sub $16,%rsp-এর পর — আরও ১৬ কমেছেযা লক্ষ্য করবেন:
১. compute-এ ঢোকার ঠিক মুহূর্তে (prologue চলার আগে), x/2xg $rsp দিয়ে দেখা প্রথম ৮ byte-টাই return address — disassemble main-এর সাথে মিলিয়ে যাচাই করুন এটা ঠিক call compute-এর পরের instruction-এর ঠিকানা।
২. প্রতিটা prologue instruction-এর পর rsp/rbp ঠিক এই লেসনের ব্যাখ্যা অনুযায়ী বদলাচ্ছে — push-এ ৮ কমা, mov-এ সমান হওয়া, sub-এ আরও কমা।
৩. info frame কমান্ড চালিয়ে দেখুন GDB নিজেই “Saved registers” আর “frame base” হিসেবে ঠিক এই লেসনের diagram-এর তথ্যই দেখায়।
stack সত্যিই নিচের দিকে বাড়ে, call সত্যিই return address push করে, আর prologue-এর প্রতিটা instruction ঠিক এই লেসনের ব্যাখ্যা অনুযায়ী rsp/rbp বদলায় — সবগুলোই hands-on যাচাইযোগ্য।
নিজে বানান
Stack buffer overflow-র mechanism নিজের চোখে দেখুন
- একটা vulnerable.c ফাইল লিখুন যেখানে একটা ফাংশন char buf[8] ডিক্লেয়ার করে আর strcpy দিয়ে একটা বড় আর্গুমেন্ট সেই buffer-এ কপি করে, কোনো bounds-check ছাড়াই
- gcc -fno-stack-protector -z execstack -g vulnerable.c -o vulnerable দিয়ে কম্পাইল করুন — মডার্ন প্রোটেকশন ইচ্ছাকৃতভাবে বন্ধ রেখে, শুধু mechanism দেখার জন্য
- একটা ছোট (৮ byte-এর কম) input দিয়ে চালিয়ে দেখুন প্রোগ্রাম স্বাভাবিকভাবে চলে
- gdb-এ ফাংশনে breakpoint বসিয়ে buf-এর ঠিকানা আর saved return address-এর ঠিকানা নোট করুন (এই লেসনের frame diagram ব্যবহার করে — কতটা দূরত্ব তাদের মধ্যে গণনা করুন)
- একটা দীর্ঘ (৩০+ byte) input দিয়ে চালান, দেখুন প্রোগ্রাম segfault করে — gdb-তে ব্যাখ্যা করুন ঠিক কোন ঠিকানায় crash হলো আর কেন সেটা এই লেসনের diagram-এর সাথে সামঞ্জস্যপূর্ণ
vulnerable.c:
#include <string.h>
#include <stdio.h>
void vulnerable(char *input) {
char buf[8];
strcpy(buf, input); // ★ কোনো bounds-check নেই — এখানেই সমস্যা
printf("buf = %s\n", buf);
}
int main(int argc, char **argv) {
if (argc > 1) vulnerable(argv[1]);
return 0;
}কী ঘটছে, এই লেসনের diagram দিয়ে ব্যাখ্যা: buf একটা local variable, তাই এটা rbp-র নিচে (negative offset-এ) থাকে — ঠিক আগের সেকশনের a, b-র মতোই। কিন্তু buf মাত্র ৮ byte-এর জন্য বরাদ্দ। strcpy জানে না এই সীমা — এটা শুধু ততক্ষণ byte কপি করতে থাকে যতক্ষণ না একটা null byte (\0) পায়। যদি input ৮ byte-এর বেশি হয়, অতিরিক্ত byte-গুলো buf-এর ঠিক পরের মেমরিতে লেখা হতে থাকে — আর diagram অনুযায়ী buf-এর ঠিক “উপরে” (উচ্চতর ঠিকানায়) থাকে saved rbp, তারপর return address। যথেষ্ট লম্বা input দিলে, লেখাটা saved-rbp ছাড়িয়ে return address-কেই ওভাররাইট করে দেয়।
ret execute হওয়ার মুহূর্তে (আগের সেকশনের সতর্কবাণী মনে করুন — ret অন্ধভাবে বিশ্বাস করে), CPU সেই ওভাররাইট-হওয়া, এখন আক্রমণকারী-নিয়ন্ত্রিত মান rip-এ বসিয়ে সেখানে জাম্প করে — সাধারণত একটা অবৈধ ঠিকানা, তাই segfault। এটাই স্ট্যাক-বাফার-ওভারফ্লো আক্রমণের mechanism; কীভাবে এটাকে নিয়ন্ত্রিতভাবে exploit করে arbitrary code চালানো যায় — সেই কৌশল ইচ্ছাকৃতভাবে এখানে আলোচনা করা হচ্ছে না (security মডিউলে সম্পূর্ণ বিস্তারিত আসবে)।
নিজে বাড়ান: gdb-এ buf-এর ঠিকানা আর saved-return-address-এর ঠিকানা বিয়োগ করে দূরত্ব বের করুন — এটাই ঠিক কত byte input দিলে return address touch হবে তার নির্ভুল হিসাব। প্রেডিক্ট করুন, তারপর পরীক্ষা করে যাচাই করুন।
বাস্তব সিস্টেমে
Stack frame যেখানে বাস্তব সিদ্ধান্ত নির্ধারণ করে
Debugger backtrace (bt) — frame-pointer chain হাঁটা। gdb-র bt কমান্ড ঠিক saved-rbp চেইন অনুসরণ করে সম্পূর্ণ call stack পুনর্গঠন করে — প্রতিটা frame-এর saved rbp তার parent frame-এর rbp-এ নিয়ে যায়, chain ধরে ধরে উপরে ওঠা যায়। এই লেসনের diagram-টাই ঠিক সেই chain-এর একটা লিংক।
Stack overflow (recursion অতিরিক্ত গভীর) — guard page আর segfault। যদি recursion (বা শুধু অনেক বেশি push) rsp-কে stack-এর জন্য বরাদ্দ অঞ্চলের বাইরে ঠেলে দেয়, OS-এর বসানো একটা guard page-এ ধাক্কা লাগে — একটা page fault ঘটে, যা “stack overflow” নামে পরিচিত crash-এর কারণ (গত মডিউলের misconception সেকশনে এই একই যন্ত্রণা সংক্ষেপে উল্লেখ হয়েছিল)।
Stack canary — buffer overflow-র সরাসরি প্রতিক্রিয়ায় জন্ম নেওয়া একটা প্রতিরক্ষা। আধুনিক compiler (gcc ডিফল্টে) local buffer আর saved-rbp/return-address-এর মাঝখানে একটা random “canary” মান বসিয়ে দেয়, আর epilogue-এ ফেরার ঠিক আগে সেটা যাচাই করে — যদি canary বদলে গেছে (মানে buffer-এর সীমা ছাড়িয়ে কিছু লেখা হয়েছে), প্রোগ্রাম নিয়ন্ত্রিতভাবে abort করে, return address ওভাররাইট-হওয়া অবস্থায় ret execute হওয়ার আগেই। এই লেসনের BuildIt exercise-এ -fno-stack-protector দিয়ে ঠিক এই প্রতিরক্ষাটাই বন্ধ রাখা হয়েছিল, mechanism স্পষ্ট দেখানোর জন্য।
ASLR (Address Space Layout Randomization) — ঠিকানা অনুমান করা কঠিন করে তোলা। প্রতিবার প্রোগ্রাম চালানোর সময় stack (আর heap, shared library) একটা randomized ঠিকানায় বসানো হয় — এমনকি যদি একটা আক্রমণকারী return address ওভাররাইট করতে পারে, ঠিক কোন ঠিকানায় জাম্প করাবে সেটা অনুমান করা কঠিন হয়ে যায়।
NX bit / DEP (Data Execution Prevention) — stack-কে “non-executable” চিহ্নিত করা। Page-table-এ একটা bit (আগের মডিউলে memory-hierarchy লেসনের সংশ্লিষ্ট বিষয়) stack-অঞ্চলকে “code execute করা যাবে না” হিসেবে চিহ্নিত করে — এমনকি যদি আক্রমণকারী নিজের কোড stack-এ ঢুকিয়ে দিতেও পারে, CPU সেখান থেকে fetch-execute করতে অস্বীকার করবে। এই লেসনের BuildIt exercise-এ -z execstack দিয়ে ইচ্ছাকৃতভাবে এই প্রতিরক্ষাটাও বন্ধ রাখা হয়েছিল।
Frame-pointer omission (-fomit-frame-pointer) — একটা modern trade-off। Optimization level বাড়ালে (-O2+) compiler প্রায়ই rbp-কে আর frame pointer হিসেবে ব্যবহার করে না, বরং একটা সাধারণ general-purpose register হিসেবে মুক্ত করে দেয় (extra register মানে extra performance)। তখন locals সরাসরি rsp-র সাপেক্ষে (variable offset-সহ, compiler নিজে track করে) address হয়। এতে debugger/backtrace-এর জন্য frame-pointer-chain হাঁটা অসম্ভব হয়ে যায় — তখন আলাদা metadata (DWARF Call Frame Information) ব্যবহার করে stack unwind করতে হয়, ঠিক এই কারণেই profiling tool-এ -fno-omit-frame-pointer প্রায়ই আলাদা করে চালু করতে হয়।
Exception handling / panic unwinding। C++ exception বা Rust-এর panic ঠিক এই একই stack-frame chain “unwind” করে — প্রতিটা frame-এর destructor/cleanup চালিয়ে, একটা উপযুক্ত handler খুঁজে পাওয়া পর্যন্ত উপরের দিকে উঠতে থাকে।
যে ভুলগুলো সবাই করে
“rsp আর rbp সবসময় একই মান ধরে রাখে, ফাংশনের ভেতরে যেকোনো মুহূর্তে।”
ভুল। শুধু prologue-এর ঠিক পরের মুহূর্তে (mov %rsp, %rbp execute হওয়ার পরপরই) তারা সমান থাকে। এরপর, ফাংশনের body-তে যদি আরও push হয় বা local buffer-এর জন্য আরও জায়গা নেওয়া হয়, rsp নিচে নামতে থাকে, কিন্তু rbp স্থির থাকে — এটাই rbp-কে “frame pointer” হিসেবে আলাদা রাখার আসল কারণ (hood সেকশনে দেখেছেন)। যদি দুটো সবসময় সমান থাকত, rbp রাখারই কোনো দরকার হতো না।
“call আর ret বিশেষ, push/pop-এর সাথে সম্পর্কহীন যাদুকরী instruction।”
সম্পূর্ণ ভুল, আর এটা এই লেসনের কেন্দ্রীয় সংশোধন। call আসলে “push return address, তারপর jump” — hood সেকশনে দেখেছেন এটা ঠিক আগের সেকশনের push-এর একই মাইক্রো-mechanics ব্যবহার করে। ret ঠিক তার বিপরীত — “pop, কিন্তু destination rip”। কোনো নতুন hardware primitive নেই — শুধু stack-এর সাধারণ push/pop যন্ত্রপাতির উপর দুটো বিশেষ-উদ্দেশ্য instruction গড়ে তোলা হয়েছে।
“Stack উপরের দিকে বাড়ে, বাস্তব জীবনের একটা স্তূপের (stack of plates) মতোই — এটাই সবচেয়ে স্বাভাবিক।”
বাস্তব জীবনের স্তূপের সাথে সাদৃশ্যটা LIFO (Last In, First Out) আচরণের জন্য ঠিক, কিন্তু ঠিকানার দিকের জন্য বিপরীত — x86-64-এ (এবং বেশিরভাগ আধুনিক আর্কিটেকচারে) stack memory-র উঁচু ঠিকানা থেকে শুরু করে নিচের দিকে বাড়ে। push মানে ঠিকানার সাংখ্যিক মান কমা, বাড়া না। এই লেসনের concept সেকশনের diagram-এ এই দিকটা স্পষ্টভাবে দেখানো হয়েছে — heap-এর সাথে “মাঝখানে মিলিত হওয়া” এড়ানোই এর ব্যবহারিক কারণ।
“Local variable-গুলো নিরাপদে আলাদা, নামযুক্ত বাক্সে থাকে — একটার overflow অন্যটাকে স্পর্শ করতে পারে না।”
এটাই ঠিক সেই বিভ্রম যা buffer overflow ভেঙে দেয়। Compiler-এর কাছে char buf[8] মানে “৮ byte কনটিগুয়াস মেমরি, rbp-র একটা নির্দিষ্ট offset থেকে শুরু” — এর বেশি কিছু না। এর সীমা hardware বা runtime দ্বারা কোনোভাবে এনফোর্স হয় না (যদি না compiler আলাদাভাবে stack-canary-র মতো একটা প্রতিরক্ষা যোগ করে, যা ডিফল্টে অনেক ক্ষেত্রে থাকে কিন্তু guaranteed না)। buf-এর সীমা ছাড়িয়ে লেখা মানে ঠিক তার পরের মেমরি-ঠিকানায় লেখা — যা এই লেসনের diagram অনুযায়ী হতে পারে saved rbp, তারপর return address। “নাম” (buf, a, b) শুধু compiler-এর নিজের হিসাবরক্ষণের সুবিধা, কোনো runtime protection না।
বুঝেছেন কি না দেখুন
1স্ট্যান্ডার্ড prologue-এর তিনটা instruction লিখুন, আর প্রতিটার উদ্দেশ্য এক বাক্যে বলুন। epilogue-এর leave instruction ঠিক কোন দুইটা instruction-এর shortcut?
স্মরণ
leave instruction ঠিক কোন দুইটা instruction-এর shortcut?Prologue: push %rbp (caller-র frame pointer বাঁচানো, কারণ rbp callee-saved), mov %rsp, %rbp (একটা স্থির frame pointer স্থাপন), sub $N, %rsp (local variable-এর জন্য N byte সংরক্ষণ)।
leave হলো mov %rbp, %rsp (locals deallocate) আর pop %rbp (caller-র rbp ফিরিয়ে দেওয়া) — এই দুইটার shortcut, একটা single instruction-এ।
2call instruction-কে কেন “push return address, তারপর jump” — এই দুইটা আলাদা instruction হিসেবে না রেখে একটা একক, atomic instruction হিসেবে ডিজাইন করা হয়েছে?
যুক্তি
call instruction-কে কেন “push return address, তারপর jump” — এই দুইটা আলাদা instruction হিসেবে না রেখে একটা একক, atomic instruction হিসেবে ডিজাইন করা হয়েছে?যদি push আর jump দুইটা আলাদা instruction হতো, তাদের মাঝখানে একটা interrupt বা exception ঘটার সম্ভাবনা থাকত — যেমন একটা hardware interrupt ঠিক push-এর পর কিন্তু jump-এর আগে ঘটল। তখন একটা অসংগত অবস্থা তৈরি হতো — return address push হয়ে গেছে stack-এ, কিন্তু PC এখনও পুরোনো ফাংশনেই আছে, jump হয়নি। একটা একক atomic instruction হিসেবে call এই সমস্যা সম্পূর্ণ দূর করে — হয় দুইটা কাজই একসাথে সম্পন্ন হবে (interrupt হলে instruction-এর মাঝখানে না, শেষে বা শুরুতেই হ্যান্ডল হবে), নাহলে কিছুই ঘটবে না — কোনো আধা-সম্পন্ন অবস্থা কখনো observable হবে না।
3নিচের ফাংশনের জন্য একটা stack frame diagram আঁকুন (prologue শেষ হওয়ার মুহূর্তে) — ঠিকানাগুলো rbp-র সাপেক্ষে (offset আকারে) লিখুন।
long triple(long n) {
long a = n;
long b = n * 2;
long c = n * 3;
return a + b + c;
}
প্রয়োগ
rbp-র সাপেক্ষে (offset আকারে) লিখুন।long triple(long n) {
long a = n;
long b = n * 2;
long c = n * 3;
return a + b + c;
}তিনটা local variable (a, b, c), প্রতিটা ৮ byte (long) — প্রলগ হবে push %rbp; mov %rsp, %rbp; sub $24, %rsp।
rbp+8 → return address
rbp+0 → saved rbp (rbp এখানে বসে থাকে)
rbp-8 → a
rbp-16 → b
rbp-24 → c ← rsp এখানেলক্ষ্য করুন — প্রতিটা variable declaration-এর ক্রম অনুযায়ী পরপর নিচের দিকে (আরও negative offset-এ) বসে, ঠিক আগের সেকশনের দুই-variable উদাহরণের প্যাটার্ন বাড়িয়ে।
4আগের প্রশ্নের triple ফাংশনে, ধরুন a-এর জায়গায় একটা char buf[24] থাকত (একটা array, তিনটা আলাদা long না)। কমপক্ষে কত byte লম্বা একটা input strcpy(buf, input)-এ দিলে সেটা saved-rbp-কে ওভাররাইট করতে শুরু করবে? Return address ওভাররাইট করতে কত byte লাগবে?
প্রয়োগ
triple ফাংশনে, ধরুন a-এর জায়গায় একটা char buf[24] থাকত (একটা array, তিনটা আলাদা long না)। কমপক্ষে কত byte লম্বা একটা input strcpy(buf, input)-এ দিলে সেটা saved-rbp-কে ওভাররাইট করতে শুরু করবে? Return address ওভাররাইট করতে কত byte লাগবে?buf rbp-24-এ শুরু হয়ে ২৪ byte জুড়ে থাকে, তাই এটা rbp-24 থেকে rbp-1 পর্যন্ত জায়গা দখল করে। সঠিক ২৪ byte-এর মধ্যে লেখা নিরাপদ। ২৫তম byte থেকেই saved-rbp-র (rbp+0) প্রথম byte ওভাররাইট শুরু হবে।
Saved-rbp নিজে ৮ byte (rbp+0 থেকে rbp+7), তাই সেটা সম্পূর্ণ ওভাররাইট করতে লাগবে ২৪+৮ = ৩২ byte। ৩৩তম byte থেকে return address (rbp+8) ওভাররাইট শুরু হবে — অর্থাৎ কমপক্ষে ৩৩ byte input দিলে return address-এর প্রথম byte স্পর্শ হবে, ৪০ byte দিলে পুরো ৮-byte return address সম্পূর্ণ ওভাররাইট হয়ে যাবে।
5স্ট্যাক ক্যানারি (stack canary) buffer overflow-কে “প্রতিরোধ” করে না — এটা শুধু সনাক্ত করে, তারপর প্রোগ্রাম abort করে দেয়। ASLR আর NX bit-ও প্রতিটাই আলাদাভাবে bypass করা সম্ভব (বাস্তবে হয়েছে, বিভিন্ন কৌশলে)। তাহলে কেন আধুনিক সিস্টেম এই তিনটাকেই একসাথে ব্যবহার করে, একটাকেই “যথেষ্ট” মনে না করে?
ডিজাইন
প্রতিটা প্রতিরক্ষা আলাদা আলাদা ধাপ কঠিন করে তোলে একটা সফল আক্রমণের, একা কোনোটাই সম্পূর্ণ নিশ্ছিদ্র না:
- Stack canary সনাক্ত করে buffer overflow ঘটেছে, কিন্তু শুধু তখনই যখন overflow সরাসরি canary-র উপর দিয়ে return address পর্যন্ত পৌঁছায় — একটা সুনির্দিষ্ট আক্রমণ কৌশল (যেমন সরাসরি একটা নির্দিষ্ট memory-write বাগ ব্যবহার করে canary এড়িয়ে যাওয়া) এটাকে বাইপাস করতে পারে।
- ASLR ঠিকানা random করে, কিন্তু যদি আক্রমণকারী কোনোভাবে একটা প্রকৃত ঠিকানা “leak” করাতে পারে (একটা আলাদা bug দিয়ে), randomization-এর সুবিধা নষ্ট হয়ে যায়।
- NX bit stack-কে non-executable করে, কিন্তু return-oriented programming (ROP)-এর মতো কৌশল নতুন কোড না বসিয়ে, বিদ্যমান executable কোডের ছোট ছোট টুকরো (already-executable, তাই NX প্রযোজ্য না) একসাথে জোড়া দিয়ে আক্রমণ করে — এটা canary সফলভাবে এড়িয়ে গেলেই সম্ভব।
মূল অন্তর্দৃষ্টি — “defense in depth” (স্তরে স্তরে প্রতিরক্ষা)। একটা আক্রমণকে সফল হতে হলে প্রতিটা স্তর আলাদাভাবে bypass করতে হয় — canary এড়াতে হয়, তারপর ASLR-এর randomization সত্ত্বেও একটা কার্যকর ঠিকানা বের করতে হয়, তারপর NX সত্ত্বেও code execute করানোর একটা পথ (যেমন ROP) খুঁজে বের করতে হয়। একটা একক প্রতিরক্ষা perfect না হলেও, তিনটা একসাথে একটা আক্রমণের ব্যবহারিক জটিলতা বহুগুণ বাড়িয়ে দেয় — এই থিমটা security মডিউলে আরও গভীরভাবে ফিরে আসবে।
এরপর কী
পরের প্রশ্ন — এই সব instruction কীভাবে সিদ্ধান্ত নেয়
এই লেসনে আমরা দেখেছি একটা ফাংশনের ভেতরে-বাইরে যাওয়ার mechanics — call, ret, prologue, epilogue। কিন্তু একটা ফাংশনের ভেতরে, if/while/for-এর মতো control flow কীভাবে assembly-তে রূপ নেয় — এখনও অস্পষ্ট। গত মডিউলের registers-pc-flags লেসনে flags register-এর সংক্ষিপ্ত আভাস আর digital-logic/combinational-subtractors-comparators লেসনের ZF/CF/SF/OF সার্কিট — এই দুটোই এখন একসাথে কাজে লাগবে।
পরের লেসনে আমরা দেখব cmp/test কীভাবে ঠিক সেই flags সেট করে, আর conditional jump-এর পুরো পরিবার (je, jl, jg, jb, ja, …) কীভাবে সেই flags পড়ে সিদ্ধান্ত নেয় — আর সবচেয়ে গুরুত্বপূর্ণ, কেন signed আর unsigned comparison-এর জন্য আলাদা jump instruction লাগে, যদিও cmp নিজে হুবহু একই থাকে।
আরও পড়ুন
- Computer Systems: A Programmer's Perspective, §3.7.4-3.7.5 — Stack Frame Structure — Randal E. Bryant, David R. O'Hallaron · Stack frame layout, prologue/epilogue, আর buffer-overflow mechanism-এর প্রামাণ্য textbook আলোচনা
- Smashing the Stack for Fun and Profit — Aleph One (Phrack, 1996) · Stack buffer overflow-র মূল, ঐতিহাসিকভাবে প্রভাবশালী লেখা — এই লেসনের mechanism-অংশের ভিত্তি
- System V Application Binary Interface — AMD64 Architecture Processor Supplement, §3.2.2 — The Stack Frame · stack alignment, red zone, আর frame layout-এর আনুষ্ঠানিক সংজ্ঞা