Foundationপ্রথম নীতি থেকে
LEVEL 10লেসন ১১/২০অ্যাডভান্সড৫৫ মিনিট

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-এর দখল হয়ে যায়।

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

  • একটা fixed-size stack buffer-এ bounds-unsafe copy function (যেমন strcpy) দিয়ে attacker-controlled ডেটা লেখা হলে ঠিক কীভাবে সেই ডেটা saved frame pointer আর return address-কে ওভাররাইট করে ফেলে — বাইট-লেভেলে, নিজের ভাষায় ব্যাখ্যা করতে পারবেন
  • কেন ret instruction অন্ধভাবে stack-top-এর মান বিশ্বাস করে, আর সেই অন্ধ-বিশ্বাসের কারণে ওভাররাইট হওয়া return address কীভাবে execution-কে attacker-নির্ধারিত যেকোনো ঠিকানায় জাম্প করায় তা যুক্তি দিয়ে ব্যাখ্যা করতে পারবেন
  • একটা faithful Python সিমুলেশন নিজে চালিয়ে দেখাতে পারবেন যে ঠিক কতটুকু oversized input return-address স্লটকে touch করে — আংশিক ওভাররাইটে এলোমেলো crash আর পূর্ণ ওভাররাইটে attacker-নিয়ন্ত্রিত জাম্পের পার্থক্য ব্যাখ্যা করতে পারবেন
  • shellcode ধারণাগতভাবে কী, আর কেন buffer overflow-কে শুধু crash না বানিয়ে arbitrary code execution-এ রূপান্তর করা সম্ভব হয় তা বর্ণনা করতে পারবেন
  • C-জাতীয় ভাষার unchecked memory access আর Rust/Python/Java-র structural bounds-checking-এর মধ্যে root-cause পার্থক্য তুলনা করে বলতে পারবেন কেন এই বাগ-শ্রেণি আসলে ভাষার ডিজাইন-সিদ্ধান্তের ফসল

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

আগে এটা বুঝি

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 ছিল, এখন কোড।
Shellcode injection — একই buffer-এ ডেটা আর কোড, return address ফিরে যায় নিজের ভেতরেই।

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-এর নিজস্ব জায়গার মধ্যেই
৯-১৬ bytename + saved-rbp (আংশিক/পূর্ণ)name-এর সীমা পার — কিন্তু return address এখনও অক্ষত
১৭-২৩ bytesaved-rbp + return address (আংশিক)return address-এর কিছু byte বদলেছে, কিছু আসল রয়ে গেছে
২৪ byteপুরো return addressattacker-এর তিনটা অংশ (buf-filler, rbp-filler, target address) হুবহু বসেছে
২৫+ byteframe-এর বাইরে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 ০-৭
                └─────────────────────┘
                     নিম্ন ঠিকানা
handle_login-এর stack frame, overflow-এর আগে — assembly লেসনের একই vocabulary, নতুন উদাহরণ।
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 নিজে সেই সীমারেখা জানে না।
Overflow-এর মার্চ — strcpy একটাই দিকে এগোয়, buf থেকে saved-rbp হয়ে return address পর্যন্ত।

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

EXPERIMENT

আংশিক বনাম পূর্ণ ওভাররাইট — সংখ্যায়

macOS / Linux / Windows (Python 3)· ১৫ মিনিট
# 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।

নিজে বানান

BUILD IT

StackFrame সিমুলেশন — vulnerable_copy বনাম safe_copy

Python · ●●●○○
  1. একটা StackFrame ক্লাস লিখুন যেটা ২৪-byte bytearray-কে buf/saved_rbp/return_address — তিনটা নামযুক্ত অঞ্চলে ভাগ করে, ঠিক assembly লেসনের diagram-এর ক্রম মেনে
  2. vulnerable_copy লিখুন — strcpy-র মতোই unbounded, buf-এর ৮-byte সীমা সম্পর্কে কিছুই জানে না
  3. attacker_input বানান — ৮ byte buf-filler + ৮ byte saved-rbp-filler + ঠিক ৮ byte attacker-এর টার্গেট ঠিকানা, মোট ২৪ byte
  4. vulnerable_copy চালিয়ে assert করুন frame.return_address == attacker-এর টার্গেট — সিমুলেটেড return address সত্যিই বদলে গেছে প্রমাণ করুন
  5. 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-এর বাইরে লেখেইনি।

নিজে বাড়ান:

  1. safe_copy-তে max_len-এর বদলে buffer পুরোপুরি ভরে না গিয়ে একটা null-terminator-এর জন্য জায়গা রাখা দরকার কিনা ভাবুন — বাস্তব C-তে strncpy এখানেই একটা কুখ্যাত ফাঁদ ফেলে (null-terminate না করেই থেমে যায়), সেটা কেন সমস্যা তা strncpy-র man page থেকে যাচাই করুন
  2. StackFrame-এ একটা চতুর্থ region caller_frame যোগ করুন, আর attacker_input-কে ২৫+ byte বানিয়ে দেখুন সেটা কীভাবে return-address-এর পরেও লেখা চালিয়ে যায় — বাস্তবে এটাই caller-এর frame corrupt করে বা unmapped page-এ পৌঁছে segfault ঘটায়
  3. vulnerable_copy-কে real strcpy-র আরও কাছাকাছি করুন — একটা 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-এর ঠিক কোন দুইটা জিনিস “উপরে” (উচ্চতর ঠিকানায়) বসে থাকে, আর ঠিক কোন ক্রমে?

স্মরণ

প্রথমে saved rbp (caller-এর frame pointer), তারপর তার উপরে return address — assembly লেসনের diagram অনুযায়ী rbp+0 আর rbp+8। এই ক্রমই মানে overflow প্রথমে saved-rbp ছোঁয়, তারপর return address — উল্টো ক্রমে না।

2

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 নেই।

3

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 সেকশনে দেখা গেছে)।

4

Experiment-এ 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 এত গুরুত্বপূর্ণ।

5

Build সেকশনে 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) — দুই স্তরের সমাধান কী প্রস্তাব করবেন, আর কীভাবে অগ্রাধিকার ঠিক করবেন?

ডিজাইন

স্বল্পমেয়াদী: প্রতিটা 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-এর নথিভুক্ত ইতিহাস