Stack Buffer Overflow — Return Address দখল করার ক্লাসিক কৌশল
Memory Safety and Stack Buffer Overflow
একটা fixed-size stack buffer-এ bounds-unsafe copy attacker-controlled ডেটা দিয়ে উপচে দিলে overflow-এর tail সরাসরি saved return address-কে ওভাররাইট করে দেয় — আর ret অন্ধভাবে সেই মান বিশ্বাস করেই জাম্প করে, তাই একটা সাধারণ copy-বাগ পুরো control flow-এর দখল হয়ে যায়।
আগে এটা বুঝি
assembly মডিউলের stack frame লেসনে একটা
সতর্কবাণী তুলে রাখা হয়েছিল, বিস্তারিত না গিয়ে: ret instruction অন্ধভাবে
বিশ্বাস করে stack-এর top-এ একটা বৈধ return address আছে, আর কোনো local
buffer-এর সীমা hardware বা runtime দিয়ে এনফোর্স হয় না। আজকের লেসনে সেই
দুইটা বাক্যকে পুরোপুরি খুলে দেখব — কারণ এই দুইটা বাক্য মিলিয়েই তৈরি হয়
কম্পিউটিং ইতিহাসের সবচেয়ে দীর্ঘজীবী দুর্বলতা-শ্রেণি: stack buffer
overflow।
গল্পটা প্রায় বিব্রতকরভাবে সহজ। একটা ফাংশন একটা fixed-size local buffer
নেয় — ধরুন ৮ byte। একটা bounds-unsafe copy function (strcpy-জাতীয়)
সেই buffer-এ ব্যবহারকারীর দেওয়া ডেটা কপি করে, কিন্তু buffer কত বড় তা
যাচাই না করেই। যদি input ৮ byte-এর বেশি হয়, অতিরিক্ত byte-গুলো কোথায়
যায়? কোথাও “হারিয়ে” যায় না — সেগুলো buffer-এর ঠিক পরের মেমরি-ঠিকানায় লেখা
হতে থাকে, নির্বিচারে। আর assembly মডিউলের diagram অনুযায়ী, একটা local
buffer-এর ঠিক পরের ঠিকানায় বসে থাকে saved frame pointer, তারপর —
return address।
এই একটা সরল সত্য থেকেই আজকের পুরো লেসন গড়ে উঠবে: যে ডেটা ব্যবহারকারী পাঠিয়েছে, সেটাই যদি ঠিক ততটা লম্বা হয় যতটা দরকার সেই return address-কে ছুঁতে, তাহলে ব্যবহারকারী — বা attacker — আক্ষরিক অর্থেই নির্ধারণ করে দিচ্ছে প্রোগ্রাম তার ফাংশন থেকে বেরিয়ে কোথায় যাবে। আজ আমরা এটা শুধু বলব না — একটা faithful Python সিমুলেশন নিজে চালিয়ে, বাইট-বাই-বাইট, দেখাব ঠিক কোন offset-এ কী ঘটে।
মূল ধারণা
Recap — stack frame-এ কে কোথায় বসে
assembly মডিউলের লেসন থেকে সংক্ষেপে মনে করিয়ে দিই, কারণ আজকের পুরো
আলোচনা এই একটা ছবির ওপর দাঁড়িয়ে। একটা ফাংশনের prologue শেষ হওয়ার পরে,
rbp-র সাপেক্ষে stack frame-এ চারটা জিনিস এই ক্রমে বসে থাকে (উচ্চ ঠিকানা
থেকে নিম্ন ঠিকানার দিকে):
rbp+8 → return address ← call এই push করেছিল, ret এখান থেকেই পড়বে
rbp+0 → saved rbp ← prologue-এর push %rbp, caller-এর frame pointer
rbp-8 → local variable ১ ← এখান থেকে নিচের দিকে locals শুরু
...তিনটা তথ্য এখানে গুরুত্বপূর্ণ, আজকের জন্য: (১) local variable-গুলো
rbp-র নিচে (নিম্ন ঠিকানায়) বসে, saved-rbp আর return address তার
উপরে (উচ্চ ঠিকানায়)। (২) stack নিচের দিকে বাড়ে, কিন্তু একটা array
বা buffer-এর ভেতরে লেখা সবসময় উচ্চ ঠিকানার দিকে এগোয় — buf[0],
তারপর buf[1], buf[2], … — এই দুইটা দিক একসাথে ধরলেই বোঝা যায় কেন
একটা local buffer-এর overflow সরাসরি saved-rbp আর return address-এর
দিকে “হাঁটে”, অন্য কোনো এলোমেলো দিকে না। (৩) ret কোনো verification
ছাড়াই rbp+8-এর মান পড়ে rip-এ বসায় — এটাই আজকের bug-এর একমাত্র শর্ত।
ন্যূনতম vulnerable ফাংশন
void handle_login(char *username) {
char name[8];
strcpy(name, username); // ★ কোনো bounds-check নেই
printf("Welcome, %s\n", name);
}name একটা ৮-byte local buffer — compiler-এর কাছে এর মানে শুধু “৮ byte
কনটিগুয়াস মেমরি, rbp-র একটা নির্দিষ্ট offset থেকে শুরু”, রেসিপির কোনো
“এখানে থামো” চিহ্ন নেই। strcpy(dest, src)-এর সংজ্ঞা: src-এর
প্রতিটা byte dest-এ কপি করো, যতক্ষণ না একটা null byte (\0) পাওয়া
যায় — dest কত বড়, সেই প্রশ্নটাই strcpy-র signature-এ নেই। যদি
username (attacker-নিয়ন্ত্রিত, ধরুন কোনো HTTP header বা network
packet থেকে আসা) ৮ byte-এর বেশি লম্বা হয়, strcpy নির্বিকারে কপি চালিয়ে
যাবে — name-এর সীমা ছাড়িয়ে, ঠিক ওপরের সেকশনের diagram অনুযায়ী প্রথমে
saved rbp, তারপর return address-এর দিকে।
ভেতরে কী ঘটছে
Byte-বাই-byte — overflow ঠিক কীভাবে “হাঁটে”
name[8]-এর overflow ধাপে-ধাপে অনুসরণ করা যাক। ধরুন frame-এর layout
ঠিক আগের সেকশনের মতো: name ৮ byte, তারপর saved-rbp ৮ byte, তারপর
return address ৮ byte — মোট ২৪ byte-এর একটা ধারাবাহিক অঞ্চল। strcpy
attacker_input-এর byte-গুলো ক্রমানুসারে লিখে যায়:
- byte ১-৮:
name[0]থেকেname[7]পর্যন্ত — এখনও সম্পূর্ণ বৈধ, এটাইname-এর নিজস্ব জায়গা। - byte ৯-১৬: এখন
name-এর সীমা পার হয়ে গেছে — এই byte-গুলো লেখা হচ্ছে saved-rbp-র জায়গায়। এখনও crash হচ্ছে না (saved-rbp শুধু epilogue-এpop %rbp-এ ব্যবহার হয়), কিন্তু ক্ষতি শুরু হয়ে গেছে। - byte ১৭-২৪: এখন return address-এর নিজস্ব ৮ byte — এখানেই আজকের পুরো গল্পের কেন্দ্র। ১৭তম byte থেকেই return address-এর প্রথম byte ওভাররাইট হতে শুরু করে; ২৪তম byte পর্যন্ত পৌঁছালে পুরো return address-ই attacker-এর লেখা মান হয়ে যায়।
লক্ষ্য করুন — attacker-এর কোনো বিশেষ hardware access লাগেনি, কোনো
privilege escalation trick লাগেনি। শুধু username-এর length নিয়ন্ত্রণ
করেই attacker পুরো ২৪ byte-এর মধ্যে শেষ ৮ byte ঠিক কী মান পাবে তা
নির্ধারণ করে ফেলেছে — কারণ strcpy-র কাছে “buffer”, “saved rbp”, “return
address” — এই নামগুলোর কোনো অর্থই নেই, সবই শুধু ধারাবাহিক byte।
Shellcode — শুধু জাম্প নয়, জাম্প করে কোথায়
এতক্ষণ ধরে নিয়েছি return address ওভাররাইট মানেই একটা “এলোমেলো” ঠিকানা, তাই crash। কিন্তু ১৯৯৬-এ Aleph One-এর Smashing the Stack for Fun and Profit যে ধাক্কাটা দিয়েছিল সেটা এখানেই — যদি attacker এলোমেলো নয়, বরং সুনির্দিষ্ট, তার নিজের পছন্দের একটা ঠিকানা return address-এ বসায়, আর সেই ঠিকানায় CPU-কে execute করার মতো কিছু একটা রেখে দেয়, তাহলে overflow আর crash না — arbitrary code execution।
কৌশলটা conceptually সরল: যেহেতু username-এর পুরো content-ই attacker
নিয়ন্ত্রণ করে, সে শুধু শেষ ৮ byte-এ return address বসায় না — শুরুর
দিকের byte-গুলোতে নিজের লেখা ছোট্ট, হাতে-লেখা মেশিন-কোডের একটা টুকরো
(“shellcode” — ঐতিহাসিকভাবে প্রায়ই একটা shell স্পন করত, তাই নাম)
ঢুকিয়ে দেয়, তারপর return address-এ এমন একটা ঠিকানা বসায় যেটা buffer-এর
ভেতরেই ফিরে যায় — নিজের বসানো shellcode-এর দিকে।
attacker_input = [ shellcode bytes ......... ][ saved-rbp filler ][ return_addr = নিজের ঠিকানা ]
▲ │
└──────────── ret এই ঠিকানায় জাম্প করে ◄─────────────────┘
ret চালানোর পরে rip = buffer-এরই একটা ঠিকানা — CPU সেখান থেকে fetch করে
attacker-এর বসানো byte-গুলোকেই এখন থেকে "instruction" হিসেবে execute
করা শুরু করে দেয়। ডেটা যা এতক্ষণ শুধু byte ছিল, এখন কোড।ret এখানেও নিজের সংজ্ঞা মেনেই কাজ করছে — কোনো নতুন দুর্বলতা লাগেনি,
শুধু আগের overflow-এরই একটা আরও সুনিপুণ সংস্করণ। বাস্তব শোষণ (exploitation)
আরও জটিল — buffer-এর ঠিক ঠিকানা আন্দাজ করা, alignment ঠিক রাখা,
NOP-sled দিয়ে ঠিকানার সামান্য ভুলকেও সহনীয় করা — কিন্তু সেই কারিগরি
বিস্তারিত এই লেসনের বিষয় না। আজকের জন্য যথেষ্ট: একটা overflow যেটা
crash করায় আর একটা overflow যেটা code execute করায় — দুইটার mechanism
হুবহু এক, পার্থক্য শুধু attacker কতটা সুনির্দিষ্টভাবে byte-গুলো
বেছেছে।
উদাহরণ
কত byte লাগবে — হাতে হিসাব
আগের সেকশনের name[8] ফাংশনের জন্য, কমপক্ষে কত byte-এর input দিলে ঠিক
কী touch হয় — এই টেবিলটা শুধু গণনা, assembly লেসনের diagram-এর সরাসরি
প্রয়োগ:
| Input length | কী touch হয় | ব্যাখ্যা |
|---|---|---|
| ১-৮ byte | শুধু name | পুরোপুরি বৈধ, name-এর নিজস্ব জায়গার মধ্যেই |
| ৯-১৬ byte | name + saved-rbp (আংশিক/পূর্ণ) | name-এর সীমা পার — কিন্তু return address এখনও অক্ষত |
| ১৭-২৩ byte | saved-rbp + return address (আংশিক) | return address-এর কিছু byte বদলেছে, কিছু আসল রয়ে গেছে |
| ২৪ byte | পুরো return address | attacker-এর তিনটা অংশ (buf-filler, rbp-filler, target address) হুবহু বসেছে |
| ২৫+ byte | frame-এর বাইরে | caller-এর frame-এ লেখা শুরু, বা unmapped memory — বাস্তবে প্রায়ই সরাসরি segfault |
সবচেয়ে গুরুত্বপূর্ণ সারি তৃতীয়টা — ১৭ থেকে ২৩ byte। এখানেই সবচেয়ে বিভ্রান্তিকর পরিস্থিতি ঘটে: return address আংশিক বদলে গেছে, attacker যা চেয়েছিল ঠিক তা নয়, অথচ মূল বৈধ ঠিকানাও আর নেই। ফলাফল প্রায় সবসময় একটা এলোমেলো, অবৈধ ঠিকানা — crash, কিন্তু attacker-এর নিয়ন্ত্রিত crash না। এই পার্থক্যটা — “কাছাকাছি কিন্তু ভুল” বনাম “ঠিক যতটুকু দরকার” — পরের সেকশনে সংখ্যায় প্রমাণ করা হবে।
ছবিতে
উচ্চ ঠিকানা
┌─────────────────────┐
rbp+8 │ return address │ ← attacker-এর আসল লক্ষ্য, offset ১৬-২৩
├─────────────────────┤
rbp+0 (rbp) │ saved rbp │ ← পথের মাঝে পড়ে, offset ৮-১৫
├─────────────────────┤
rbp-8 │ name[8] (buf) │ ← attacker-এর entry point, offset ০-৭
└─────────────────────┘
নিম্ন ঠিকানাattacker_input: [ AAAAAAAA ][ BBBBBBBB ][ যে ঠিকানা attacker চায় ]
│ │ │
▼ ▼ ▼
frame: name[0..7] saved_rbp[0..7] return_addr[0..7]
(offset 0-7) (offset 8-15) (offset 16-23)
strcpy শুধু একটা লম্বা, undifferentiated byte-stream লিখছে — "name",
"saved rbp", "return address" নামগুলো শুধু programmer আর compiler-এর
মানসিক সীমারেখা, memory নিজে সেই সীমারেখা জানে না।নিজে চালিয়ে দেখুন
আংশিক বনাম পূর্ণ ওভাররাইট — সংখ্যায়
# offsets.py — একই ২৪-byte frame-এ ভিন্ন input-length ভিন্ন কতটুকু "বিজয়" করে
import struct
BUF_SIZE, RBP_SIZE, RET_SIZE = 8, 8, 8
FRAME_SIZE = BUF_SIZE + RBP_SIZE + RET_SIZE # 24
LEGIT_RET = 0x0000555555555169
ATTACKER_RET = 0x0000555555de0d0d # attacker-এর engineered target (toy shellcode ঠিকানা)
def make_frame():
mem = bytearray(FRAME_SIZE)
mem[16:24] = struct.pack("<Q", LEGIT_RET)
return mem
def vulnerable_copy(mem, data):
# strcpy(buf, data)-এর faithful আচরণ: buf-এর ৮-byte সীমা সম্পর্কে
# কিছুই জানে না, শুধু data ফুরানো পর্যন্ত byte-by-byte লিখে যায়।
for i, b in enumerate(data):
mem[i] = b
def ret_addr(mem):
return struct.unpack("<Q", mem[16:24])[0]
lengths = [4, 8, 12, 16, 17, 20, 23, 24]
attacker_tail = struct.pack("<Q", ATTACKER_RET) # শেষ ৮ byte সবসময় attacker-এর target
print(f"legit return address = {LEGIT_RET:#018x}")
print(f"attacker-এর target address = {ATTACKER_RET:#018x}")
print(f"frame layout: buf[0:8] saved_rbp[8:16] return_address[16:24]\n")
for n in lengths:
# attacker_input: প্রথম দিকটা filler 'A', একদম শেষের দিকে attacker_tail-এর
# যতটুকু অংশ n দৈর্ঘ্যে ধরে ততটুকু বসে
if n <= 16:
payload = b"A" * n
else:
tail_needed = n - 16
payload = b"A" * 16 + attacker_tail[:tail_needed]
mem = make_frame()
vulnerable_copy(mem, payload)
ra = ret_addr(mem)
touched = "না" if ra == LEGIT_RET else ("আংশিক" if ra != ATTACKER_RET else "সম্পূর্ণ")
print(f"input length={n:2d} -> return_address = {ra:#018x} touched={touched}")প্রত্যাশিত output (deterministic — হুবহু এটাই আসবে):
legit return address = 0x0000555555555169
attacker-এর target address = 0x0000555555de0d0d
frame layout: buf[0:8] saved_rbp[8:16] return_address[16:24]
input length= 4 -> return_address = 0x0000555555555169 touched=না
input length= 8 -> return_address = 0x0000555555555169 touched=না
input length=12 -> return_address = 0x0000555555555169 touched=না
input length=16 -> return_address = 0x0000555555555169 touched=না
input length=17 -> return_address = 0x000055555555510d touched=আংশিক
input length=20 -> return_address = 0x0000555555de0d0d touched=সম্পূর্ণ
input length=23 -> return_address = 0x0000555555de0d0d touched=সম্পূর্ণ
input length=24 -> return_address = 0x0000555555de0d0d touched=সম্পূর্ণসংখ্যাগুলো নিজেই আগের সেকশনের হাতে-করা টেবিল প্রমাণ করছে — length=16
পর্যন্ত return address স্পর্শই হয়নি, length=17-এ প্রথম byte বদলে
0x...5169 হয়ে যায় 0x...510d — একটা সম্পূর্ণ এলোমেলো, অকেজো ঠিকানা,
attacker-এর টার্গেটও না, আসল ঠিকানাও না। কিন্তু সবচেয়ে চমকপ্রদ সারি
length=20: মাত্র ২০ byte-এই — return address-এর পুরো ৮ byte পূরণ হওয়ার
আগেই — ফলাফল হুবহু ATTACKER_RET-এর সমান হয়ে গেছে।
একটা ২৪-byte frame-এ (buf ৮ + saved-rbp ৮ + return-address ৮) input length ১৬ পর্যন্ত return address সম্পূর্ণ অক্ষত থাকে, ১৭-১৯ byte-এ আংশিক (এলোমেলো) বদল ঘটে, আর ২০+ byte-এই — পুরো ৮ byte পূরণ হওয়ার আগেই — attacker-এর টার্গেট সম্পূর্ণ মিলে যায়, কারণ real 64-bit ঠিকানার উপরের byte-গুলো সবসময় predictable।
নিজে বানান
StackFrame সিমুলেশন — vulnerable_copy বনাম safe_copy
- একটা StackFrame ক্লাস লিখুন যেটা ২৪-byte bytearray-কে buf/saved_rbp/return_address — তিনটা নামযুক্ত অঞ্চলে ভাগ করে, ঠিক assembly লেসনের diagram-এর ক্রম মেনে
- vulnerable_copy লিখুন — strcpy-র মতোই unbounded, buf-এর ৮-byte সীমা সম্পর্কে কিছুই জানে না
- attacker_input বানান — ৮ byte buf-filler + ৮ byte saved-rbp-filler + ঠিক ৮ byte attacker-এর টার্গেট ঠিকানা, মোট ২৪ byte
- vulnerable_copy চালিয়ে assert করুন frame.return_address == attacker-এর টার্গেট — সিমুলেটেড return address সত্যিই বদলে গেছে প্রমাণ করুন
- safe_copy লিখুন — max_len-এর বেশি হলে truncate করে, একই attacker_input দিয়ে চালিয়ে assert করুন return_address অপরিবর্তিত রয়ে গেছে
লক্ষ্য: শুধু বলা নয়, দেখানো — একই attacker-নিয়ন্ত্রিত ইনপুট, একই ফাংশনের দুইটা ভিন্ন বাস্তবায়নে গেলে, একবার return address চুরি হয়ে যায়, আরেকবার একটা byte-ও নড়ে না।
# stack_sim.py — vulnerable_copy (strcpy-faithful) বনাম safe_copy (bounds-checked)
import struct
BUF_SIZE = 8
class StackFrame:
"""একটা function-এর stack frame-এর byte-level সিমুলেশন — ঠিক আগের
(assembly) লেসনের diagram-এর ক্রম মেনে: buf সর্বনিম্ন ঠিকানায়,
তারপর saved rbp, তারপর সবচেয়ে উঁচু ঠিকানায় return address।
offset 0..7 -> buf (8 byte, local variable)
offset 8..15 -> saved_rbp (8 byte, caller-র frame pointer)
offset 16..23 -> return_addr(8 byte, call-এর push করা ঠিকানা)
"""
def __init__(self, legit_return):
self.mem = bytearray(24)
self.mem[16:24] = struct.pack("<Q", legit_return)
@property
def return_address(self):
return struct.unpack("<Q", self.mem[16:24])[0]
@property
def saved_rbp(self):
return self.mem[8:16]
def vulnerable_copy(frame, data: bytes):
"""strcpy(buf, data)-এর faithful আচরণ: buf মাত্র ৮ byte, কিন্তু এই
ফাংশন সেই সীমা সম্পর্কে কিছুই জানে না — data ফুরানো পর্যন্ত
frame.mem-এ offset 0 থেকে byte-by-byte লিখে যায়, buf-এর সীমানা
তো দূরের কথা, পুরো frame-এর সীমানাও চেক করে না।"""
for i, b in enumerate(data):
frame.mem[i] = b
def safe_copy(frame, data: bytes, max_len=BUF_SIZE):
"""একই কাজ, কিন্তু bounds-checked — buf-এর ঘোষিত সীমার বেশি কখনো
লেখে না। max_len-এর বেশি হলে truncate করে, ফেরত দেয় কতটা সত্যিই
গ্রহণ করা হলো।"""
accepted = data[:max_len]
for i, b in enumerate(accepted):
frame.mem[i] = b
return accepted
LEGIT_RETURN = 0x0000555555555169 # ফাংশন যেখানে স্বাভাবিকভাবে ফেরার কথা
ATTACKER_TARGET = 0x0000555555de0d0d # attacker-এর engineered return address
# attacker_input: ৮ byte buf-filler + ৮ byte saved_rbp-filler + ঠিক ৮ byte
# attacker-এর target — এই তিনটা মিলিয়ে ঠিক ২৪ byte, যাতে শেষ ৮ byte-ই
# return_address স্লটের ওপর হুবহু গিয়ে বসে।
attacker_input = b"A" * 8 + b"B" * 8 + struct.pack("<Q", ATTACKER_TARGET)
print(f"attacker_input length = {len(attacker_input)} byte")
print(f"attacker_input (hex) = {attacker_input.hex()}")
print("\n--- vulnerable_copy: unbounded, strcpy-স্টাইল ---")
frame = StackFrame(LEGIT_RETURN)
print(f"overflow-এর আগে return_address = {frame.return_address:#018x} (legit)")
vulnerable_copy(frame, attacker_input)
print(f"overflow-এর পরে return_address = {frame.return_address:#018x}")
assert frame.return_address == ATTACKER_TARGET, "attacker return address বসাতে ব্যর্থ হলো!"
print(f"assert frame.return_address == ATTACKER_TARGET -> PASSED")
print("-> শুধু একটা 'strcpy(buf, input)'-এর unbounded copy দিয়েই")
print(" attacker নিজের পছন্দমতো ঠিকানা return_address-এ বসিয়ে দিয়েছে।")
print("\n--- safe_copy: bounds-checked, একই attacker_input দিয়ে ---")
frame2 = StackFrame(LEGIT_RETURN)
print(f"overflow-চেষ্টার আগে return_address = {frame2.return_address:#018x} (legit)")
accepted = safe_copy(frame2, attacker_input, max_len=BUF_SIZE)
print(f"safe_copy গ্রহণ করল মাত্র {len(accepted)} byte (max_len={BUF_SIZE}): {accepted!r}")
print(f"overflow-চেষ্টার পরে return_address = {frame2.return_address:#018x}")
assert frame2.return_address == LEGIT_RETURN, "safe_copy-তেও return address বদলে গেছে!"
print(f"assert frame2.return_address == LEGIT_RETURN -> PASSED")
zero8 = b"\x00" * 8
print(f"assert frame2.saved_rbp == zero8 -> {frame2.saved_rbp == zero8}")
print("-> একই attacker_input, শুধু একটা length-check যোগ করাতেই")
print(" saved_rbp আর return_address অক্ষত রয়ে গেছে — buf নিজেই truncate হয়েছে।")প্রত্যাশিত output (deterministic — হুবহু এটাই আসবে):
attacker_input length = 24 byte
attacker_input (hex) = 414141414141414142424242424242420d0dde5555550000
--- vulnerable_copy: unbounded, strcpy-স্টাইল ---
overflow-এর আগে return_address = 0x0000555555555169 (legit)
overflow-এর পরে return_address = 0x0000555555de0d0d
assert frame.return_address == ATTACKER_TARGET -> PASSED
-> শুধু একটা 'strcpy(buf, input)'-এর unbounded copy দিয়েই
attacker নিজের পছন্দমতো ঠিকানা return_address-এ বসিয়ে দিয়েছে।
--- safe_copy: bounds-checked, একই attacker_input দিয়ে ---
overflow-চেষ্টার আগে return_address = 0x0000555555555169 (legit)
safe_copy গ্রহণ করল মাত্র 8 byte (max_len=8): b'AAAAAAAA'
overflow-চেষ্টার পরে return_address = 0x0000555555555169
assert frame2.return_address == LEGIT_RETURN -> PASSED
assert frame2.saved_rbp == zero8 -> True
-> একই attacker_input, শুধু একটা length-check যোগ করাতেই
saved_rbp আর return_address অক্ষত রয়ে গেছে — buf নিজেই truncate হয়েছে।দুইটা assert-ই পাস করেছে, আর সেটাই মূল কথা। vulnerable_copy-তে
frame.return_address হুবহু ATTACKER_TARGET-এ পরিণত হয়েছে — কোনো
hand-wave না, সরাসরি byte-arithmetic-এর ফলাফল। safe_copy-তে একই
attacker_input, একই ২৪ byte চেষ্টা করা হয়েছে, কিন্তু max_len=8
check-এ accepted truncate হয়ে গেছে মাত্র b'AAAAAAAA'-এ — saved_rbp
আর return_address কোনোটাই touch হয়নি, কারণ safe_copy কখনো frame.mem
-এর ৮ offset-এর বাইরে লেখেইনি।
নিজে বাড়ান:
safe_copy-তেmax_len-এর বদলে buffer পুরোপুরি ভরে না গিয়ে একটা null-terminator-এর জন্য জায়গা রাখা দরকার কিনা ভাবুন — বাস্তব C-তেstrncpyএখানেই একটা কুখ্যাত ফাঁদ ফেলে (null-terminate না করেই থেমে যায়), সেটা কেন সমস্যা তাstrncpy-র man page থেকে যাচাই করুনStackFrame-এ একটা চতুর্থ regioncaller_frameযোগ করুন, আর attacker_input-কে ২৫+ byte বানিয়ে দেখুন সেটা কীভাবে return-address-এর পরেও লেখা চালিয়ে যায় — বাস্তবে এটাই caller-এর frame corrupt করে বা unmapped page-এ পৌঁছে segfault ঘটায়vulnerable_copy-কে realstrcpy-র আরও কাছাকাছি করুন — একটা null-byte (\x00) পেলে থেমে যাওয়ার logic যোগ করুন, তারপর দেখানattacker_input-এর মাঝখানে একটা\x00থাকলে attack কীভাবে ব্যর্থ হয় (এটাই বাস্তব shellcode-এ null byte এড়ানোর কৌশলের কারণ)
বাস্তব সিস্টেমে
বাস্তব জগতে stack buffer overflow
Morris Worm (১৯৮৮) — প্রথম ব্যাপক, নথিভুক্ত exploitation। Robert
Tappan Morris-এর worm ইন্টারনেটের একটা বড় অংশ সংক্রমিত করেছিল ঠিক এই
একই কৌশলের একটা আদি রূপ ব্যবহার করে — Unix-এর fingerd সার্ভিসে
gets()-এর একটা unbounded stack write, যেটা একটা fixed-size local
buffer-এর সীমা ছাড়িয়ে ঠিক আজকের লেসনের mechanism-এই control flow
হাইজ্যাক করত। Eugene Spafford-এর ১৯৮৮-এর technical report এই ঘটনার
প্রথম বিস্তারিত post-mortem — সেখানেই প্রথমবারের মতো একটা buffer
overflow-এর ব্যাপক, ইন্টারনেট-স্কেল পরিণতি নথিভুক্ত হয়।
CWE-121 — আজও একটা সক্রিয় শ্রেণি। MITRE-এর Common Weakness Enumeration-এ stack-based buffer overflow একটা নির্দিষ্ট, নামযুক্ত শ্রেণি (CWE-121) — CVE database অনুসন্ধান করলে আজও প্রতি বছর শত শত নতুন entry এই একই শ্রেণিতে ট্যাগ হয়, বেশিরভাগ embedded firmware, network device, আর legacy C/C++ codebase-এ। প্রায় চার দশক পুরনো এই bug-ক্লাস এখনও নতুন নয় — এটাই পরের লেসনের মূল প্রশ্ন তুলে দেয়: যদি bug ঠেকানো এত কঠিন, তাহলে তার পরিণতি ঠেকানো যায় কিনা।
আজকের C/C++ codebase-এ। OpenSSL, curl, ইমেজ-ডিকোডার লাইব্রেরি, network protocol parser — যেকোনো জায়গায় untrusted, variable-length input একটা fixed-size stack buffer-এ কপি হয়, সেখানেই এই bug-ক্লাসের সম্ভাবনা থেকে যায়। এটাই আজকের misconception সেকশনের একটা মূল বিষয় — এটা “পুরনো, অপ্রাসঙ্গিক” bug না, বরং একটা structural ঝুঁকি যা language-এর মৌলিক ডিজাইন-সিদ্ধান্ত থেকে আসে।
যে ভুলগুলো সবাই করে
“Buffer overflow শুধু programmer একটু অসতর্ক হলেই ঘটে — যথেষ্ট সতর্ক থাকলে সম্পূর্ণ এড়ানো যায়।”
“সতর্কতা” একটা মানুষী গুণ, কোনো compiler-এনফোর্সড নিয়ম না — তাই এই দাবি কার্যত অসত্য প্রমাণিত হয়েছে বারবার, চার দশক ধরে, অভিজ্ঞ engineer-দের হাতেও। OpenSSL, Linux kernel, প্রতিটা বড় C codebase-এই এই ক্লাসের bug নিয়মিত পাওয়া গেছে, দক্ষ team-এর হাতেও। সমস্যাটা ব্যক্তিগত সতর্কতার না — C-জাতীয় ভাষা কোনো structural guarantee দেয় না যে একটা array-write তার নিজের সীমার মধ্যে থাকবে; প্রতিটা bounds-check প্রোগ্রামারকে হাতে লিখতে হয়, প্রতিটা কল-সাইটে, প্রতিবার। “root cause” সেকশনে এই পার্থক্যটাই আরও স্পষ্ট হবে।
“Buffer overflow মানেই crash — এটা reliability সমস্যা, security সমস্যা না।”
Experiment সেকশনেই এর বিপরীত প্রমাণ আছে: length=17-এ crash-জাতীয়
এলোমেলো ফলাফল, কিন্তু length=20+-এ হুবহু attacker-নির্ধারিত মান।
একটা careless বা দুর্ঘটনাজনিত overflow (যেমন খুব লম্বা কোনো ইনপুট,
ইচ্ছাকৃত না) প্রায়ই crash-এ শেষ হয়, কারণ overwrite-হওয়া byte-গুলো
এলোমেলো। কিন্তু একটা ইচ্ছাকৃতভাবে ইঞ্জিনিয়ার করা overflow — hood
সেকশনের shellcode আলোচনা মনে করুন — crash নয়, বরং attacker-এর পছন্দমতো
কোড execute করাতে পারে। “crash” আর “control hijack” — দুইটাই একই
root-cause bug-এর সম্ভাব্য ফলাফল, শুধু attacker কতটা সুনির্দিষ্ট তার
ওপর নির্ভর করে কোনটা ঘটবে।
“শুধু strcpy/gets-এর মতো পুরনো 'unsafe' function এড়িয়ে চললেই buffer overflow এড়ানো যায়।”
strcpy, gets, sprintf — এগুলো এড়ানো সাহায্য করে, কিন্তু root cause
ঠিক করে না। memcpy(dest, src, len)-এর মতো “নিরপেক্ষ” ফাংশনও যদি len
কোনো attacker-নিয়ন্ত্রিত মান থেকে ভুলভাবে গণনা করা হয় (একটা
integer-overflow বা off-by-one bug-এর কারণে), ঠিক একই ধরনের overflow
ঘটে — memcpy নিজে “unsafe” তালিকায় নেই, তবু bug-টা এখানেও একই:
destination-এর প্রকৃত সীমার সাথে লেখা length-এর মিল যাচাই না করা। মূল
প্রশ্ন কখনোই “কোন function ব্যবহার করছি” না — মূল প্রশ্ন “লেখা
length কি সত্যিই destination-এর সীমার মধ্যে, প্রতিবার, প্রতিটা
পথে?”।
“আধুনিক OS-এ stack canary, ASLR, NX bit থাকলে buffer overflow বাগ আর সমস্যা না।”
এই তিনটা প্রতিরক্ষাই আজকের bug-কে exploit করা কঠিন করে তোলে —
কিন্তু bug নিজে, name[8]-এর সীমা ছাড়িয়ে লেখা, তখনও ঘটে। Stack canary
overflow ঘটে যাওয়ার পরে সেটা ধরে (আর প্রোগ্রাম তখনও crash করে —
একটা denial-of-service)। ASLR শুধু ঠিকানা অনুমান কঠিন করে, ঠিকানা-leak
হওয়া কোনো bug থাকলে অকেজো হয়ে যায়। NX bit stack-কে non-executable করে,
কিন্তু return-oriented programming-এর মতো কৌশল নতুন কোড না বসিয়েই কাজ
চালিয়ে যেতে পারে। এই চারটা প্রতিরক্ষাই পরের লেসনের বিষয় — আজকের জন্য
মূল কথা এইটুকু: mitigation bug-টাকে মুছে ফেলে না, শুধু আক্রমণের খরচ
বাড়িয়ে দেয়।
বুঝেছেন কি না দেখুন
1একটা function-এর stack frame-এ name[8] local buffer-এর ঠিক কোন দুইটা
জিনিস “উপরে” (উচ্চতর ঠিকানায়) বসে থাকে, আর ঠিক কোন ক্রমে?
স্মরণ
name[8] local buffer-এর ঠিক কোন দুইটা
জিনিস “উপরে” (উচ্চতর ঠিকানায়) বসে থাকে, আর ঠিক কোন ক্রমে?প্রথমে saved rbp (caller-এর frame pointer), তারপর তার উপরে return
address — assembly লেসনের diagram অনুযায়ী rbp+0 আর rbp+8। এই
ক্রমই মানে overflow প্রথমে saved-rbp ছোঁয়, তারপর return address —
উল্টো ক্রমে না।
2ret instruction কেন কোনো “যাচাই” ছাড়াই stack-এর top-এর মানকে বৈধ
return address ধরে নেয়? এই ডিজাইন-সিদ্ধান্তটাই কেন buffer overflow-কে
সম্ভব করে তোলে?
যুক্তি
ret instruction কেন কোনো “যাচাই” ছাড়াই stack-এর top-এর মানকে বৈধ
return address ধরে নেয়? এই ডিজাইন-সিদ্ধান্তটাই কেন buffer overflow-কে
সম্ভব করে তোলে?ret-এর সংজ্ঞায় validation কোনো ধাপ নেই — এটা শুধু “যা stack-এর top-এ
আছে, তা rip-এ কপি করো” — একটা hardware-level চুক্তি যেটা ধরে নেয়
stack সবসময় সঠিকভাবে balanced থাকবে, শুধু বৈধ call-এর push করা মানই
top-এ থাকবে। এই ধরে-নেওয়া (assumption) সাধারণত সত্যি হয়, কিন্তু কোনো
হার্ডওয়্যার প্রক্রিয়া সেটা নিশ্চিত করে না। যখন একটা local buffer-এর
overflow সেই top-এর মান বদলে দেয়, ret কোনো পার্থক্য করতে পারে না —
এটাই আসল দুর্বলতা: বিশ্বাস আছে, verification নেই।
3name[8]-এর পরে ৮ byte saved-rbp, তারপর ৮ byte return address — এই
লেআউটে ঠিক কততম byte থেকে return address-এর প্রথম byte ওভাররাইট শুরু
হবে, আর কততম byte দিলে পুরো return address সম্পূর্ণ ওভাররাইট হয়ে
যাবে?
প্রয়োগ
name[8]-এর পরে ৮ byte saved-rbp, তারপর ৮ byte return address — এই
লেআউটে ঠিক কততম byte থেকে return address-এর প্রথম byte ওভাররাইট শুরু
হবে, আর কততম byte দিলে পুরো return address সম্পূর্ণ ওভাররাইট হয়ে
যাবে?name ৮ byte (offset ০-৭) + saved-rbp ৮ byte (offset ৮-১৫) সম্পূর্ণ
ভরাট হওয়ার পরেই return address শুরু (offset ১৬)। তাই ১৭তম byte
থেকে return address-এর প্রথম byte touch হবে। পুরো return address
(৮ byte, offset ১৬-২৩) সম্পূর্ণ ওভাররাইট করতে লাগবে মোট ২৪ byte —
অর্থাৎ ২৪তম byte-এই পুরো return address attacker-নিয়ন্ত্রিত হয়ে
যাবে (উপরের ঠিকানার canonical-form কাকতালীয় মিলের কারণে বাস্তবে কম
byte-েও পূর্ণ মিল ঘটতে পারে, experiment সেকশনে দেখা গেছে)।
4Experiment-এ length=17-এর ফলাফল ছিল একটা সম্পূর্ণ এলোমেলো,
অকেজো ঠিকানা — attacker-এর টার্গেটও না, আসল ঠিকানাও না। কেন একটা
“আংশিক” ওভাররাইট প্রায়ই attacker-নিয়ন্ত্রিত জাম্পের বদলে শুধু crash
ঘটায়?
যুক্তি
length=17-এর ফলাফল ছিল একটা সম্পূর্ণ এলোমেলো,
অকেজো ঠিকানা — attacker-এর টার্গেটও না, আসল ঠিকানাও না। কেন একটা
“আংশিক” ওভাররাইট প্রায়ই attacker-নিয়ন্ত্রিত জাম্পের বদলে শুধু crash
ঘটায়?Return address একটা single ৮-byte সংখ্যা — তার কিছু byte আসল, কিছু attacker-লিখিত হলে ফলাফল একটা hybrid মান, যা না আসল ঠিকানা, না attacker-এর ইচ্ছাকৃত ঠিকানা। এই hybrid মান প্রায় নিশ্চিতভাবেই কোনো মাপা, valid, executable মেমরি-ঠিকানা নয় (২^৬৪ সম্ভাব্য মানের মধ্যে মাত্র সামান্য একটা অংশ বৈধ, mapped, executable) — তাই CPU সেখানে জাম্প করার চেষ্টা করে সাধারণত একটা page fault / segfault-এ শেষ হয়, নিয়ন্ত্রিত কোড execute হওয়ার বদলে। একটা সফল, নিয়ন্ত্রিত hijack-এর জন্য attacker-কে ঠিক ততটুকু byte নিয়ন্ত্রণ করতে হয় যতটুকু একটা সম্পূর্ণ, বৈধ, কার্যকর ঠিকানা তৈরি করতে যথেষ্ট — এই জন্যই experiment সেকশনের canonical- address insight এত গুরুত্বপূর্ণ।
5Build সেকশনে safe_copy-তে একই ২৪-byte attacker_input দিয়েও
return_address অপরিবর্তিত থেকে গেল। ঠিক কোন একটা লাইন এই সুরক্ষা
নিশ্চিত করেছে, আর সেটা vulnerable_copy-র সাথে ঠিক কোথায় আলাদা?
প্রয়োগ
safe_copy-তে একই ২৪-byte attacker_input দিয়েও
return_address অপরিবর্তিত থেকে গেল। ঠিক কোন একটা লাইন এই সুরক্ষা
নিশ্চিত করেছে, আর সেটা vulnerable_copy-র সাথে ঠিক কোথায় আলাদা?accepted = data[:max_len] লাইনটাই মূল পার্থক্য — এটা data-কে
max_len (এখানে ৮) byte-এ truncate করে, তারপর শুধু সেই truncated
অংশটুকুই frame.mem-এ লেখে। vulnerable_copy enumerate(data)-এর
পুরো length জুড়ে লেখে, কোনো সীমা ছাড়াই। safe_copy-তে লেখার length
কখনো buffer-এর ঘোষিত সীমা (BUF_SIZE) অতিক্রম করে না, তাই saved_rbp
আর return_address-এর offset পর্যন্ত পৌঁছানোর সুযোগই তৈরি হয় না।
6একটা বড়, দশক-পুরনো C codebase রিভিউ করছেন, যেখানে বহু জায়গায়
char buf[N]; strcpy(buf, network_input);-জাতীয় প্যাটার্ন আছে, পুরো
codebase একবারে নতুন ভাষায় rewrite করা সম্ভব না। স্বল্পমেয়াদী (এই
sprint-এই করার মতো) আর দীর্ঘমেয়াদী (structural) — দুই স্তরের সমাধান
কী প্রস্তাব করবেন, আর কীভাবে অগ্রাধিকার ঠিক করবেন?
ডিজাইন
char buf[N]; strcpy(buf, network_input);-জাতীয় প্যাটার্ন আছে, পুরো
codebase একবারে নতুন ভাষায় rewrite করা সম্ভব না। স্বল্পমেয়াদী (এই
sprint-এই করার মতো) আর দীর্ঘমেয়াদী (structural) — দুই স্তরের সমাধান
কী প্রস্তাব করবেন, আর কীভাবে অগ্রাধিকার ঠিক করবেন?স্বল্পমেয়াদী: প্রতিটা strcpy/gets/unchecked-length copy খুঁজে
বের করার জন্য static-analysis tool (Coverity, cppcheck, বা compiler-এর
-Wall -Wextra -Wformat-security) চালানো, আর অন্তত সবচেয়ে বেশি
network-facing, untrusted-input-touching function-গুলোতে অবিলম্বে
bounds-checked বিকল্পে (strlcpy, বা explicit length-check-সহ
memcpy) migrate করা — ঠিক এই লেসনের safe_copy-র নীতি। পাশাপাশি,
নতুন কম্পাইলে stack canary/ASLR/NX (পরের লেসনের বিষয়) অবশ্যই enable
রাখা যাতে যা এখনও মিস হয়েছে তার exploitation অন্তত কঠিন হয়।
দীর্ঘমেয়াদী (structural): সবচেয়ে বেশি untrusted-input-parsing অংশ (network parser, file-format decoder) ধীরে ধীরে একটা memory-safe ভাষায় (Rust, বা bounds-checked wrapper library) rewrite করার পরিকল্পনা করা — কারণ এই bug-ক্লাসের প্রকৃত সমাধান ভাষার স্তরে, প্রতিটা call-site-এ programmer-এর মনে-রাখার ওপর না। অগ্রাধিকার: attack surface অনুযায়ী — যে কোড সরাসরি untrusted, network-থেকে-আসা ডেটা handle করে, সেটাই সবচেয়ে বেশি ঝুঁকিতে, তাই সেখান থেকেই শুরু করা উচিত; internal, trusted-input-only কোড তুলনামূলক কম জরুরি।
এরপর কী
এরপর কী
আজ আমরা দেখলাম একটা সরল সত্য কতটা ধ্বংসাত্মক হতে পারে — একটা fixed-size
stack buffer-এ bounds-unsafe copy, যার overflow সরাসরি saved return
address-কে ওভাররাইট করে দেয়, আর ret কোনো প্রশ্ন না করেই সেই মানে
জাম্প করে। বাইট-বাই-বাইট, আসলেই চালিয়ে দেখা গেল — কোথায় ঠিক কী touch
হয়, আর কেন “কাছাকাছি” ভুল হওয়া মানে সাধারণত শুধু crash, অথচ “ঠিক ঠিক”
হওয়া মানে সম্পূর্ণ control।
এই bug-ক্লাস চার দশক ধরে টিকে আছে কারণ এটা কোনো একটা ভাষার একটা ভুল না
— এটা C-জাতীয় ভাষার একটা মৌলিক ডিজাইন-সিদ্ধান্তের ফল: memory access
নিজে থেকে bound-checked না। Rust-এর borrow checker কম্পাইল-টাইমেই
নিশ্চিত করে একটা reference কখনো তার allocation-এর সীমা ছাড়িয়ে যাবে না;
Python বা Java-র মতো ভাষায় প্রতিটা array-access runtime-এ bound-check
হয়, সীমা ছাড়ালে একটা নিয়ন্ত্রিত exception (IndexError,
ArrayIndexOutOfBoundsException) ওঠে — মেমরি corrupt হয় না, প্রোগ্রাম
নিয়ন্ত্রিতভাবে ব্যর্থ হয়। এই তুলনাটাই বলে দেয় কেন আধুনিক সিস্টেম-প্রোগ্রামিং
ধীরে ধীরে Rust-এর দিকে ঝুঁকছে — bug-ক্লাসটা “সাবধান থাকুন” দিয়ে না,
ভাষার নিজের নিয়ম দিয়ে বন্ধ করা।
কিন্তু বিশ্বের প্রায় সব critical infrastructure আজও C/C++-এ লেখা, আর রাতারাতি rewrite সম্ভব না। প্রশ্নটা তাহলে দাঁড়ায়: bug-ক্লাসটা যদি এখনই সম্পূর্ণ মুছে ফেলা না যায়, তাহলে কি অন্তত সেটাকে নিরাপদে ব্যর্থ হতে বাধ্য করা যায় — silently control হাতছাড়া করার বদলে?
পরের লেসনে ঠিক এই প্রশ্নের চারটা উত্তর: stack canary, যেটা
return address ওভাররাইট হওয়ার আগেই সনাক্ত করে; ASLR, যেটা attacker-
এর ঠিকানা-অনুমানকেই অকেজো করার চেষ্টা করে; NX bit, যেটা shellcode
injection-কে অচল করে দেয়; আর CFI (Control-Flow Integrity), যেটা
ret/call-এর জাম্প-টার্গেটকেই যাচাই করে। চারটাই আজকের build-করা
mechanism-এর ঠিক একটা করে ধাপ টার্গেট করে — পরের লেসনে দেখব কোনটা
কোন ধাপ বন্ধ করে, আর কেন একটা একা কখনোই যথেষ্ট না।
আরও পড়ুন
- Smashing the Stack for Fun and Profit — Aleph One (Phrack, Issue 49, 1996) · Stack buffer overflow exploitation-এর প্রতিষ্ঠাতা লেখা — return-address overwrite থেকে shellcode injection পর্যন্ত পুরো কৌশলের প্রথম ব্যাপক দলিল, আজও রেফারেন্স-বিন্দু
- CWE-121: Stack-based Buffer Overflow — MITRE Corporation, Common Weakness Enumeration · এই দুর্বলতা-শ্রেণির আনুষ্ঠানিক শ্রেণিবিন্যাস — CVE database-এ হাজার হাজার entry এই একটা CWE-তে ট্যাগ হয়
- The Internet Worm Program: An Analysis — Eugene H. Spafford — Purdue University, Technical Report CSD-TR-823, 1988 · ১৯৮৮-এর Morris Worm-এর post-mortem — fingerd-এর unbounded stack write ব্যবহার করে প্রথমবারের মতো ব্যাপক ইন্টারনেট-স্কেল exploitation-এর নথিভুক্ত ইতিহাস