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

Integer Overflow-এর নিরাপত্তা পরিণতি — সংখ্যা থেকে Heap Corruption পর্যন্ত

Integer Overflow Vulnerabilities

একটা allocation-size calculation বা length-check-এ ঘটা integer wraparound যখন malloc()-এর ইনপুট বা memcpy-র size প্যারামিটার হয়ে যায়, তখন সংখ্যার একটা নিরীহ 'ভুল উত্তর' সরাসরি একটা heap buffer overflow-এ পরিণত হয় — আজকের লেসনে এই combo-bug প্যাটার্নটা toy heap আর প্রকৃত, চালিয়ে-দেখা সংখ্যা দিয়ে প্রমাণ করে দেখানো হবে, memory-safety আর্কের শেষ অধ্যায় হিসেবে।

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

  • computer-representation মডিউলের integer-overflow লেসনে দেখা wraparound mechanics-কে recap হিসেবে ধরে নিয়ে, সেই একই wraparound যখন একটা allocation-size calculation-এ ঢোকে তখন ঠিক কীভাবে একটা 'নিরাপদ, সংজ্ঞায়িত' গাণিতিক আচরণ একটা 'security-critical' bug-এ রূপান্তরিত হয় তা ব্যাখ্যা করতে পারবেন
  • count * element_size-জাতীয় একটা allocation-size calculation কীভাবে 32-bit-এ wrap করে একটা বিপজ্জনকভাবে ছোট মানে নেমে আসে, আর সেই undersized malloc()-এর ওপর একটা unchecked write loop কীভাবে adjacent heap মেমরি corrupt করে — একটা নিজে চালানো toy heap সিমুলেশন দিয়ে সংখ্যায় দেখাতে পারবেন
  • একটা signed length-check কীভাবে ঋণাত্মক input আটকাতে ব্যর্থ হয়, আর সেই একই ঋণাত্মক মান memcpy-জাতীয় ফাংশনের unsigned size_t প্যারামিটারে পৌঁছে কীভাবে একটা বিশাল ধনাত্মক সংখ্যায় পুনর্ব্যাখ্যাত হয় তা bit-pattern যুক্তি দিয়ে, প্রকৃত সংখ্যাসহ ব্যাখ্যা করতে পারবেন
  • checked, wrapping, আর saturating arithmetic — এই তিনটা policy-র মধ্যে পার্থক্য করে, একটা allocation-size calculation-এর জন্য ঠিক কোনটা সঠিক আর কেন অন্য দুইটা সেখানে বিপজ্জনক তা যুক্তি দিয়ে বলতে পারবেন
  • কেন এই পুরো bug-ক্লাসটা structurally Rust-জাতীয় ভাষার overflow-checked arithmetic দিয়ে বন্ধ করা যায়, আর কেন C/C++-এ এটা এখনও প্রতিটা call-site-এ প্রোগ্রামারের দায়িত্ব থেকে যায় — আগের memory-safety লেসনগুলোর তিন-স্তরের mitigation ফ্রেমওয়ার্কের সাথে মিলিয়ে ব্যাখ্যা করতে পারবেন

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

আগে এটা বুঝি

তিনটা লেসন আগে আমরা memory-safety-এর জগতে ঢুকেছিলাম একটা স্থানিক (spatial) বাগ দিয়ে — buffer overflow, একটা copy যেটা তার destination-এর সীমা মানে না। তারপর একটা সময়গত (temporal) বাগ — use-after-free আর double-free, যেখানে সীমানা ঠিকই ছিল কিন্তু pointer-টা ভুল সময়ে ব্যবহার হয়েছে। আজ, এই আর্কের চতুর্থ আর শেষ লেসনে, আমরা একটা তৃতীয় ধরনের ভুলের দিকে যাচ্ছি — এবার সমস্যাটা মেমরিতেই না, তার ঠিক আগে, যেখানে প্রোগ্রাম হিসাব করে ঠিক কতটুকু মেমরি দরকার।

computer-representation মডিউলের integer-overflow লেসনে আমরা মেকানিজমটা পুরোপুরি দেখেছি — unsigned arithmetic mod 2ⁿ-এ সংজ্ঞায়িতভাবে wrap করে, signed overflow C/C++-এ undefined behavior, আর দুই ক্ষেত্রেই একটা বড় গাণিতিক ফলাফল একটা fixed-width representation-এ আঁটার চেষ্টা করলে “ভুল” উত্তর ফিরে আসে। আজকের লেসন সেই মেকানিজমটা আবার প্রমাণ করবে না — সেটা prerequisite হিসেবে ধরে নেওয়া হচ্ছে। আজকের প্রশ্ন সম্পূর্ণ ভিন্ন: সেই ভুল সংখ্যাটা যখন একটা allocation-size বা একটা bounds-check-এ ঢুকে যায়, তখন কী ঘটে?

উত্তরটা এতটাই ধারাবাহিকভাবে বিধ্বংসী যে এটার নিজের নাম আছে security জগতে — “integer overflow to buffer overflow” — একটা combo-bug প্যাটার্ন যেখানে প্রথম বাগ (একটা সংখ্যার ভুল হিসাব) নিজে থেকে কোনো ক্ষতি করে না, কিন্তু সেই ভুল সংখ্যাটা একটা দ্বিতীয়, সম্পূর্ণ ভিন্ন মেকানিজম — malloc()-এর আকার-প্যারামিটার, অথবা memcpy()-র length-প্যারামিটার — কে বিভ্রান্ত করে, আর সেই বিভ্রান্তিই আসল memory corruption ঘটায়।

মূল ধারণা

দুইটা নামযুক্ত প্যাটার্ন, একই মৌলিক আকৃতি

Integer-overflow-জনিত memory-safety বাগের প্রায় সবগুলোই দুইটা প্যাটার্নের একটাতে পড়ে। দুইটাই একই আকৃতি মেনে চলে — একটা সংখ্যা wrap করে, তারপর সেই wrap-হওয়া মানটাই পরবর্তী সিদ্ধান্তের ভিত্তি হয়ে যায়, কোনো re-validation ছাড়া।

প্যাটার্ন ১ — Allocation-Size Overflow

একটা allocation-size সাধারণত এভাবে হিসাব হয়:

size = count * element_size
buf  = malloc(size)

যদি count attacker-নিয়ন্ত্রিত হয় (কোনো protocol header, ফাইল ফরম্যাট, বা network message থেকে আসা একটা “কতগুলো item আসছে” ফিল্ড), আর count * element_size-এর সত্যিকারের গাণিতিক ফলাফল size_t-এর ধারণক্ষমতা ছাড়িয়ে যায়, তাহলে size একটা wrap-হওয়া, বিপজ্জনকভাবে ছোট মান হয়ে যায় — malloc() তখন প্রত্যাশার চেয়ে অনেক ছোট একটা buffer ফেরত দেয়। সমস্যাটা এখানেই থেমে যেত যদি পরের কোড সেই ছোট buffer-এর প্রকৃত আকার মেনে চলত — কিন্তু বাস্তবে পরের write-loop প্রায়ই মূল, unwrapped count-এর ওপর ভিত্তি করেই লিখতে থাকে, কারণ সেই লুপের কাছে count আর size-এর মধ্যেকার সম্পর্কটা আর সত্যি নেই এই তথ্যটা জানার কোনো উপায় নেই।

প্যাটার্ন ২ — Signed/Unsigned Confusion

একটা length-check প্রায়ই এভাবে লেখা হয়:

if (requested_len > max_len) reject();
memcpy(dst, src, requested_len);

যদি requested_len একটা signed integer হয় (যেমন সাধারণ int), তাহলে একটা ঋণাত্মক মান এই চেক পাস করে যায় — signed domain-এ যেকোনো ঋণাত্মক সংখ্যা সবসময় max_len-এর চেয়ে ছোট। কিন্তু memcpy-র তৃতীয় প্যারামিটার (size_t) unsigned — তাই সেই একই ঋণাত্মক মান, যখন memcpy-তে পৌঁছায়, implicit conversion-এর নিয়মে (integer-overflow লেসনের ভাষায়: bit-pattern অক্ষত থেকে শুধু re-interpret) একটা বিশাল ধনাত্মক সংখ্যায় পরিণত হয় — কার্যত “সীমাহীন” একটা copy length।

Allocation-Size OverflowSigned/Unsigned Confusion
কোথায় ভাঙেmultiplication (count * element_size)comparison (signed check বনাম unsigned use)
wrap-এর দিকউপরের দিকে (বড় থেকে ছোট, mod 2ⁿ)নিচের দিকে (ঋণাত্মক থেকে বিশাল ধনাত্মক, reinterpret)
ভুক্তভোগী callmalloc(size) — ছোট buffer পায়memcpy(dst, src, len) — বিশাল length পায়
CWECWE-190CWE-191-এর ঘনিষ্ঠ আত্মীয়

আজকের hood আর experiment অংশে দুইটাই আলাদাভাবে, প্রকৃত সংখ্যা দিয়ে চালিয়ে দেখানো হবে।

ভেতরে কী ঘটছে

Allocation-Size Overflow — একটা toy heap-এ আসলে চালিয়ে দেখা

নিচের সিমুলেশন একটা flat bytearray heap আর একটা bump allocator ব্যবহার করে — আগের দুই লেসনের chunk/free-list মডেলের চেয়ে সহজ, কারণ আজকের বাগের কেন্দ্র reuse-এ না, size-এর হিসাবে। ধারাবাহিক alloc() কল ধারাবাহিক ঠিকানায় বসে — ঠিক একটা real arena/bump allocator, বা glibc-র chunk-splitting-এর adjacency-র মতোই।

# alloc_overflow.py -- size = count * element_size overflows (32-bit) to a
# small value; malloc() hands back an undersized buffer; the write step
# still writes the attacker's real payload, stomping into adjacent heap
# memory.

MASK32 = 0xFFFFFFFF
ELEMENT_SIZE = 4        # a uint32_t "record" -- e.g. a handle/pointer table entry

def compute_alloc_size(count, element_size):
    # size_t size = count * element_size;  -- on a 32-bit size_t (or a
    # 32-bit count/size variable on a 64-bit system, equally common),
    # this multiplication wraps silently. No language-level warning.
    return (count * element_size) & MASK32

class ToyHeap:
    """A flat bytearray heap + a bump allocator. Each alloc() call places
    the new chunk immediately after the previous one -- no free-list
    needed for this bug, because the flaw lives entirely in the size
    *computation* handed to malloc(), not in chunk reuse."""
    def __init__(self, size=256):
        self.mem = bytearray(size)
        self._next = 0

    def alloc(self, nbytes):
        addr = self._next
        self._next += nbytes
        return addr

    def write_u32(self, addr, offset, value):
        self.mem[addr + offset: addr + offset + 4] = value.to_bytes(4, "little")

    def read_u32(self, addr):
        return int.from_bytes(self.mem[addr:addr + 4], "little")


heap = ToyHeap()

print("== ধাপ ১: attacker একটা 'count' field পাঠাচ্ছে (protocol-এর অংশ) ==")
count = 0x40000001                       # attacker-controlled, sent on the wire
print(f"  attacker-sent count = {count}  ({hex(count)})")
print(f"  ELEMENT_SIZE        = {ELEMENT_SIZE} byte")
raw_product = count * ELEMENT_SIZE
print(f"  সত্যিকারের গাণিতিক ফল = count * ELEMENT_SIZE = {raw_product}  ({hex(raw_product)})")

alloc_size = compute_alloc_size(count, ELEMENT_SIZE)
print(f"  32-bit-এ mask করা ফল (size_t size)  = {alloc_size}  ({hex(alloc_size)})")
print(f"  -> malloc()-কে বলা হচ্ছে মাত্র {alloc_size} byte বরাদ্দ করতে,")
print(f"     যদিও attacker আসলে {count:,}টা {ELEMENT_SIZE}-byte record পাঠানোর দাবি করছে")

print("\n== ধাপ ২: buf = malloc(size) -- undersized buffer বরাদ্দ হলো ==")
buf_addr = heap.alloc(alloc_size)
print(f"  buf_addr = {hex(buf_addr)}   allocated size = {alloc_size} byte")

print("\n== ধাপ ৩: এর ঠিক পরেই allocator পরবর্তী object বসায় (victim) ==")
SENTINEL = 0xCAFEBABE
victim_addr = heap.alloc(4)
heap.write_u32(victim_addr, 0, SENTINEL)
print(f"  victim_addr = {hex(victim_addr)}  (= buf_addr + {alloc_size}, বাস্তব bump/arena allocator-এর adjacency)")
print(f"  victim-এর আসল মান = {hex(SENTINEL)}")

print("\n== ধাপ ৪: copy loop -- 'count'-এর ওপর ভিত্তি করেই লেখে, alloc_size-এর ওপর না ==")
ACTUAL_RECORDS_SENT = 3     # attacker's real wire payload for this PoC
print(f"  demo-তে দেখানোর জন্য প্রথম {ACTUAL_RECORDS_SENT}টা record লেখা হচ্ছে "
      f"(বাস্তবে attacker চাইলে আরও {count - ACTUAL_RECORDS_SENT:,}টা লিখতে পারত)")
for i in range(ACTUAL_RECORDS_SENT):
    record_value = 0x41414141 + i     # attacker-chosen payload bytes
    heap.write_u32(buf_addr, i * ELEMENT_SIZE, record_value)
    print(f"    write record[{i}] = {hex(record_value)}  at offset {i*ELEMENT_SIZE} "
          f"(absolute addr {hex(buf_addr + i*ELEMENT_SIZE)})")

print("\n== ধাপ ৫: ফলাফল -- victim-এর মান কি বদলে গেছে? ==")
new_victim = heap.read_u32(victim_addr)
print(f"  victim_addr এখনও একই ঠিকানা : {hex(victim_addr)}")
print(f"  victim-এর আসল মান ছিল      : {hex(SENTINEL)}")
print(f"  victim-এর এখনকার মান       : {hex(new_victim)}")
print(f"  victim corrupted হয়েছে?    : {new_victim != SENTINEL}")

প্রকৃত চালিয়ে পাওয়া output (deterministic — হুবহু এটাই আসবে):

== ধাপ ১: attacker একটা 'count' field পাঠাচ্ছে (protocol-এর অংশ) ==
  attacker-sent count = 1073741825  (0x40000001)
  ELEMENT_SIZE        = 4 byte
  সত্যিকারের গাণিতিক ফল = count * ELEMENT_SIZE = 4294967300  (0x100000004)
  32-bit-এ mask করা ফল (size_t size)  = 4  (0x4)
  -> malloc()-কে বলা হচ্ছে মাত্র 4 byte বরাদ্দ করতে,
     যদিও attacker আসলে 1,073,741,825টা 4-byte record পাঠানোর দাবি করছে

== ধাপ ২: buf = malloc(size) -- undersized buffer বরাদ্দ হলো ==
  buf_addr = 0x0   allocated size = 4 byte

== ধাপ ৩: এর ঠিক পরেই allocator পরবর্তী object বসায় (victim) ==
  victim_addr = 0x4  (= buf_addr + 4, বাস্তব bump/arena allocator-এর adjacency)
  victim-এর আসল মান = 0xcafebabe

== ধাপ ৪: copy loop -- 'count'-এর ওপর ভিত্তি করেই লেখে, alloc_size-এর ওপর না ==
  demo-তে দেখানোর জন্য প্রথম 3টা record লেখা হচ্ছে (বাস্তবে attacker চাইলে আরও 1,073,741,822টা লিখতে পারত)
    write record[0] = 0x41414141  at offset 0 (absolute addr 0x0)
    write record[1] = 0x41414142  at offset 4 (absolute addr 0x4)
    write record[2] = 0x41414143  at offset 8 (absolute addr 0x8)

== ধাপ ৫: ফলাফল -- victim-এর মান কি বদলে গেছে? ==
  victim_addr এখনও একই ঠিকানা : 0x4
  victim-এর আসল মান ছিল      : 0xcafebabe
  victim-এর এখনকার মান       : 0x41414142
  victim corrupted হয়েছে?    : True

মূল মুহূর্তটা ধাপ ৫-এ: victim-এর মান 0xcafebabe থেকে 0x41414142-এ বদলে গেছে — অথচ প্রোগ্রামের কোনো লাইন সরাসরি victim-কে touch করেনি। buf-এ লেখা তৃতীয় record (0x41414143, offset ৮-এ, absolute address 0x8-এ) মূলত buf-এর নিজের ৪-byte সীমা (0x0 থেকে 0x3) থেকে অনেক দূরে লেখা হয়েছে — কিন্তু ঠিক victim_addr-এর ৪ byte-এর মধ্যেই দ্বিতীয় write-টা (record[1], absolute address 0x4) পড়েছে, আর সেটাই victim-এর পুরনো মান সম্পূর্ণ প্রতিস্থাপন করে দিয়েছে। শুধু ৩টা record লিখেই — যেখানে attacker-এর দাবি করা count ছিল এক বিলিয়নেরও বেশি — corruption ঘটে গেছে, কারণ malloc()-এর কাছে “৪ byte” ছাড়া আর কিছুই জানা ছিল না।

Allocation-size overflow-এর anatomy — কোন স্তরে কী ভাঙে
  1. attacker inputএকটা "count" ফিল্ড, সম্পূর্ণ attacker-নিয়ন্ত্রিত (protocol/file-format থেকে আসা)
  2. size calculationcount * element_size — গাণিতিকভাবে সঠিক, কিন্তু size_t-এর ধারণক্ষমতা ছাড়িয়ে যায়
  3. representation (integer-overflow লেসনের বিষয়)32-bit-এ mod 2³² wrap হয়ে একটা ছোট, "বৈধ দেখতে" মান তৈরি হয়
  4. malloc()সেই ছোট মানকেই সত্য ধরে নিয়ে একটা বাস্তব, undersized buffer বরাদ্দ করে
  5. write loopমূল, unwrapped count-এর ওপর ভিত্তি করে লিখতে থাকে — buf-এর প্রকৃত আকার সম্পর্কে কিছুই জানে না

উদাহরণ

হাতে হিসাব — কোন (count, element_size) জোড়া wrap করে

Hood-সেকশনের একটা মাত্র জোড়া (0x40000001, 4) না, বরং আরও কয়েকটা বাস্তবসম্মত জোড়া হাতে হিসাব করলে বোঝা যায় এই বাগ কতটা সহজে ট্রিগার হয় — কোনো “exotic” ইনপুট লাগে না, শুধু গুণফল 2³²-এর একটা multiple-এর কাছাকাছি পড়লেই যথেষ্ট:

countelement_sizeসত্যিকারের গুণফল32-bit-এ wrap-হওয়া size
1646464 (কোনো overflow নেই — স্বাভাবিক)
0x40000000 (১,০৭৩,৭৪১,৮২৪)44,294,967,296 (2³²)0
0x40000001 (১,০৭৩,৭৪১,৮২৫)44,294,967,3004
0x80000000 (২,১৪৭,৪৮৩,৬৪৮)24,294,967,296 (2³²)0
0x20000000 (৫৩৬,৮৭০,৯১২)84,294,967,296 (2³²)0
0xFFFFFFFF (৪,২৯৪,৯৬৭,২৯৫)14,294,967,2954,294,967,295 (wrap হয়নি, কারণ ঠিক সীমার মধ্যে)

সবচেয়ে গুরুত্বপূর্ণ পর্যবেক্ষণ — উপরের তৃতীয় সারি (count = 0x40000000, element_size = 4) size = 0 তৈরি করে। বাস্তবে malloc(0) একটা implementation-defined আচরণ — অনেক allocator একটা ন্যূনতম, non-NULL pointer ফেরত দেয় (একটা বৈধ কিন্তু শূন্য-ব্যবহারযোগ্য chunk)। যদি caller-এর কোড শুধু “malloc() NULL ফেরত দেয়নি” চেক করে সন্তুষ্ট হয়ে যায় (একটা খুবই সাধারণ, যুক্তিসঙ্গত মনে হওয়া চেক), সেই চেক এখানে সম্পূর্ণ অন্ধ — buffer-এর প্রকৃত ব্যবহারযোগ্য আকার শূন্য, অথচ পরের কোড ধরে নিচ্ছে সেখানে অন্তত count-এর সমান জায়গা আছে।

এই প্যাটার্নটাই — একটা attacker-নিয়ন্ত্রিত count, একটা fixed element_size, আর তাদের গুণফল 2³²-এর কাছাকাছি পড়া — বাস্তব CVE-তে বারবার দেখা গেছে, আজকের realworld অংশে যার একটা সুনির্দিষ্ট উদাহরণ (CVE-2021-3156) বিস্তারিতভাবে দেখব।

ছবিতে

count          = 0x40000001                        (attacker-controlled)
element_size   = 0x00000004

সত্যিকারের গুণফল (গাণিতিকভাবে, কোনো bit-সীমা ছাড়া):
  0x40000001 × 4 = 0x100000004        ← ৩৩ বিট লাগে এই সংখ্যা রাখতে

কিন্তু size_t (এখানে 32-bit ধরা হয়েছে) মাত্র ৩২ বিট রাখতে পারে:
  ┌────────────────────────────────┐
  │ 1 0000 0000 0000 0000 0000 0100 │  ← পূর্ণ, সঠিক ৩৩-বিট ফলাফল
  └┬───────────────────────────────┘
   │  এই সবচেয়ে উঁচু বিটটাই storage-এ জায়গা পায়নি — নীরবে বাদ পড়ে যায়
   ▼
  ┌───────────────────────────────┐
  │ 0000 0000 0000 0000 0000 0100  │  ← size_t-তে যা আসলে সংরক্ষিত হলো
  └───────────────────────────────┘
        = 4  (malloc()-এর কাছে এটাই এখন "সত্য")
Narrow store — 64-bit-এ সত্যিকারের গুণফল, কিন্তু size_t মাত্র 32-bit-এই সংরক্ষিত হচ্ছে।
requested_len = -1        (int, signed — 32-bit two's complement)

bit pattern (এই একটাই bit pattern, কোনো conversion হয়নি):
  1111 1111 1111 1111 1111 1111 1111 1111

signed int হিসেবে পড়লে:        -1
size_t (unsigned) হিসেবে পড়লে:  4,294,967,295   (0xFFFFFFFF)

length-check (signed domain-এ):
  requested_len > max_len ?   (-1 > 1024)  =  false   ← চেক পাস করে যায়

memcpy(dst, src, requested_len) -- তৃতীয় প্যারামিটার size_t:
  একই bit-pattern, এবার unsigned হিসেবে পড়া হচ্ছে
  n = 4,294,967,295 byte  ← "copy প্রায় 4 GB", destination মাত্র 1024 byte
Signed/unsigned পুনর্ব্যাখ্যা — একই bit-pattern, দুই ভিন্ন সংখ্যা, নির্ভর করে কে পড়ছে তার ওপর।

দুইটা diagram-ই একই সত্য দুইভাবে দেখাচ্ছে: প্রথমটায় একটা বিট হারিয়ে যাচ্ছে (গণনার ফলাফল storage-এ আঁটেনি), দ্বিতীয়টায় কোনো বিট হারায়নি, শুধু একই বিট-প্যাটার্ন দুইটা ভিন্ন নিয়মে দুইবার পড়া হয়েছে। দুইটাই integer-overflow লেসনের representation-ভাষায় বলা যায়, কিন্তু পরিণতি একই — একটা মেমরি-অপারেশন যে সংখ্যাটা বিশ্বাস করছে, সেটা প্রোগ্রামারের ইচ্ছার সংখ্যা না।

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

EXPERIMENT

Signed length-check bypass — সংখ্যায়, memcpy পর্যন্ত

macOS / Linux / Windows (Python 3)· ১৫ মিনিট
# signed_unsigned_confusion.py -- a length check written with a SIGNED
# comparison lets a negative length sail through; the moment that same
# value is reinterpreted as an UNSIGNED size_t (exactly what memcpy's
# third parameter is), it becomes a huge positive number.

MASK32 = 0xFFFFFFFF
MASK64 = 0xFFFFFFFFFFFFFFFF

def as_unsigned(value_signed, bits=32):
    mask = (1 << bits) - 1
    return value_signed & mask

def vulnerable_check(requested_len, max_len=1024):
    """C-faithful: `int requested_len` (SIGNED) checked against
    `int max_len` -- both signed, so the comparison is done in the
    signed domain. A negative requested_len passes this check, because
    in signed arithmetic any negative number is < max_len."""
    if requested_len > max_len:
        return False, "প্রত্যাখ্যাত: requested_len > max_len"
    return True, "গৃহীত: requested_len <= max_len (signed comparison)"

def call_memcpy_style(requested_len, bits=32):
    """memcpy(dst, src, size_t n) -- n is UNSIGNED. Passing a negative
    int here does an implicit signed-to-unsigned conversion, exactly per
    C's usual arithmetic conversions (C11 6.3.1.3p2): the value is
    reduced modulo 2^bits, i.e. reinterpreted as its unsigned bit pattern."""
    return as_unsigned(requested_len, bits)


BUFFER_CAPACITY = 1024   # the real destination buffer's true size, in bytes

print("== ধাপ ১: স্বাভাবিক, বৈধ input দিয়ে চেক করা হচ্ছে ==")
for n in [0, 512, 1024]:
    ok, why = vulnerable_check(n, BUFFER_CAPACITY)
    print(f"  requested_len={n:6d}  ->  check passed={ok}   ({why})")

print("\n== ধাপ ২: attacker একটা ঋণাত্মক length পাঠাচ্ছে ==")
attacker_len = -1
ok, why = vulnerable_check(attacker_len, BUFFER_CAPACITY)
print(f"  requested_len = {attacker_len}")
print(f"  signed check: requested_len > max_len ?  ({attacker_len} > {BUFFER_CAPACITY}) = {attacker_len > BUFFER_CAPACITY}")
print(f"  check passed = {ok}   ({why})")
print(f"  -> negative length থামানো হয়নি, কারণ signed domain-এ -1 সবসময় 1024-এর চেয়ে ছোট")

print("\n== ধাপ ৩: সেই একই মান এবার memcpy(dst, src, n)-এ পৌঁছাচ্ছে -- n unsigned ==")
for bits, label in [(32, "32-bit size_t"), (64, "64-bit size_t")]:
    n_unsigned = call_memcpy_style(attacker_len, bits)
    print(f"  {label}: requested_len={attacker_len} (int) -> reinterpreted as size_t = {n_unsigned:,}  ({hex(n_unsigned)})")

print("\n== ধাপ ৪: তুলনা -- বাফার-ক্ষমতা বনাম memcpy আসলে কতটা কপি করতে চাইবে ==")
n32 = call_memcpy_style(attacker_len, 32)
print(f"  বাস্তব destination buffer-এর ক্ষমতা : {BUFFER_CAPACITY:,} byte")
print(f"  memcpy()-কে পাঠানো n (32-bit size_t) : {n32:,} byte")
print(f"  n > buffer capacity ?                : {n32 > BUFFER_CAPACITY}")
print(f"  overflow-এর পরিমাণ                   : {n32 - BUFFER_CAPACITY:,} byte বাফারের বাইরে")

print("\n== ধাপ ৫: তুলনা করার জন্য -- একটা সঠিকভাবে লেখা check ==")
def correct_check(requested_len, max_len=1024):
    # explicitly reject negative BEFORE trusting the upper-bound check --
    # this is the fix, not a different arithmetic trick
    if requested_len < 0:
        return False, "প্রত্যাখ্যাত: requested_len ঋণাত্মক"
    if requested_len > max_len:
        return False, "প্রত্যাখ্যাত: requested_len > max_len"
    return True, "গৃহীত"

ok2, why2 = correct_check(attacker_len, BUFFER_CAPACITY)
print(f"  requested_len={attacker_len}  ->  correct_check passed={ok2}   ({why2})")

প্রকৃত চালিয়ে পাওয়া output (deterministic — হুবহু এটাই আসবে):

== ধাপ ১: স্বাভাবিক, বৈধ input দিয়ে চেক করা হচ্ছে ==
  requested_len=     0  ->  check passed=True   (গৃহীত: requested_len <= max_len (signed comparison))
  requested_len=   512  ->  check passed=True   (গৃহীত: requested_len <= max_len (signed comparison))
  requested_len=  1024  ->  check passed=True   (গৃহীত: requested_len <= max_len (signed comparison))

== ধাপ ২: attacker একটা ঋণাত্মক length পাঠাচ্ছে ==
  requested_len = -1
  signed check: requested_len > max_len ?  (-1 > 1024) = False
  check passed = True   (গৃহীত: requested_len <= max_len (signed comparison))
  -> negative length থামানো হয়নি, কারণ signed domain-এ -1 সবসময় 1024-এর চেয়ে ছোট

== ধাপ ৩: সেই একই মান এবার memcpy(dst, src, n)-এ পৌঁছাচ্ছে -- n unsigned ==
  32-bit size_t: requested_len=-1 (int) -> reinterpreted as size_t = 4,294,967,295  (0xffffffff)
  64-bit size_t: requested_len=-1 (int) -> reinterpreted as size_t = 18,446,744,073,709,551,615  (0xffffffffffffffff)

== ধাপ ৪: তুলনা -- বাফার-ক্ষমতা বনাম memcpy আসলে কতটা কপি করতে চাইবে ==
  বাস্তব destination buffer-এর ক্ষমতা : 1,024 byte
  memcpy()-কে পাঠানো n (32-bit size_t) : 4,294,967,295 byte
  n > buffer capacity ?                : True
  overflow-এর পরিমাণ                   : 4,294,966,271 byte বাফারের বাইরে

== ধাপ ৫: তুলনা করার জন্য -- একটা সঠিকভাবে লেখা check ==
  requested_len=-1  ->  correct_check passed=False   (প্রত্যাখ্যাত: requested_len ঋণাত্মক)

ধাপ ৩-এর দুইটা লাইন একসাথে সবচেয়ে গুরুত্বপূর্ণ পর্যবেক্ষণ দেয়: একই -1, কোনো নতুন attacker input ছাড়াই, শুধু size_t-এর bit-width বদলে গেলে দুইটা সম্পূর্ণ ভিন্ন “বিপর্যয়ের মাত্রা” তৈরি করে — 32-bit সিস্টেমে প্রায় ৪ গিগাবাইট, 64-bit সিস্টেমে প্রায় ১৮.৪ কুইন্টিলিয়ন বাইট। বাস্তবে memcpy এত বড় একটা length পেলে প্রায় নিশ্চিতভাবে একটা segfault-এ crash করবে (unmapped memory পড়তে/লিখতে গিয়ে) — কিন্তু সেই crash হওয়ার আগে, attacker-এর দেওয়া প্রকৃত ডেটা যতটুকু bytes সত্যিই পাঠানো হয়েছে ততটুকু নিশ্চিতভাবে dst-এর সীমা ছাড়িয়ে লেখা হয়ে গেছে — ঠিক buffer-overflow লেসনের ভাষায়, “attacker যতটুকু নিয়ন্ত্রণ করতে পারে ততটুকুই বিপদ”, পুরো ৪ গিগাবাইট না লিখেই ক্ষতি সম্পন্ন হয়ে যায়।

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

একটা signed length-check ঋণাত্মক input আটকাতে ব্যর্থ হয় কারণ signed domain-এ যেকোনো ঋণাত্মক সংখ্যা স্বয়ংক্রিয়ভাবে max_len-এর চেয়ে ছোট — আর সেই একই bit-pattern memcpy-র unsigned size_t প্যারামিটারে পৌঁছে একটা প্রায়-4GB (32-bit) বা প্রায়-18-quintillion-byte (64-bit) copy-length হয়ে যায়।

নিজে বানান

BUILD IT

checked_alloc_size — multiplication-কে wrap করতে না দেওয়া

Python · ●●●○○
  1. একটা checked_alloc_size(count, element_size, bits) ফাংশন লিখুন যা প্রকৃত গুণফল একটা wider domain-এ (Python-এর arbitrary-precision int) হিসাব করে
  2. সেই প্রকৃত গুণফলকে bits-bit সর্বোচ্চ ধারণক্ষমতার সাথে তুলনা করুন -- truncate করার আগেই
  3. ধারণক্ষমতা ছাড়ালে একটা exception raise করুন, silent wrap হতে দেবেন না
  4. হুড-সেকশনের ঠিক একই attacker input (count=0x40000001, element_size=4) দিয়ে দেখান checked ভার্সন block করে যেখানে unchecked ভার্সন silently 4 রিটার্ন করেছিল
  5. একটা স্বাভাবিক, ছোট input দিয়ে দেখান checked ভার্সন স্বাভাবিকভাবেই কাজ করে -- এটা false-positive না

লক্ষ্য: hood-সেকশনের ঠিক একই বাগ-দৃশ্য নিয়ে, এবার size-calculation-টাকে এমনভাবে লেখা যাতে wrap হওয়া মানই কখনো malloc()-এর কাছে “সত্য” হিসেবে না পৌঁছায়। এটাই বাস্তব __builtin_mul_overflow / C23-এর ckd_mul (prerequisite লেসনে দেখা হয়েছে ckd_add-এর প্রসঙ্গে) / Rust-এর checked_mul-এর মূল ধারণার সরাসরি প্রয়োগ, শুধু allocation-size-এর প্রেক্ষাপটে।

# safe_alloc_size.py -- checked_mul-style size calculator: same attacker
# input as alloc_overflow.py's step 1-2, but this time the multiplication
# itself detects the wraparound and refuses to hand back a wrong size.

class AllocationTooLargeError(Exception):
    pass

def checked_alloc_size(count, element_size, bits=32):
    """Computes count * element_size, but WITHOUT ever letting the
    wrapped-around value escape as if it were the real answer. Mirrors
    __builtin_mul_overflow / Rust's checked_mul: compute in a wider
    domain, compare against the narrow domain's max, decide BEFORE
    truncating."""
    max_value = (1 << bits) - 1
    true_product = count * element_size          # Python ints never overflow --
                                                   # this line IS the "wider domain"
    if true_product > max_value:
        raise AllocationTooLargeError(
            f"count({count}) * element_size({element_size}) = {true_product} "
            f"> {bits}-bit সর্বোচ্চ ধারণক্ষমতা ({max_value}) -- allocation প্রত্যাখ্যাত"
        )
    return true_product


def unchecked_alloc_size(count, element_size, bits=32):
    mask = (1 << bits) - 1
    return (count * element_size) & mask


COUNT = 0x40000001         # same attacker-supplied count as the hood-section bug
ELEMENT_SIZE = 4

print("== একই attacker input, দুইটা ভিন্ন size-calculation policy ==")
print(f"  count = {COUNT:,} ({hex(COUNT)}),  element_size = {ELEMENT_SIZE}")

unchecked = unchecked_alloc_size(COUNT, ELEMENT_SIZE)
print(f"\n  unchecked_alloc_size() -> {unchecked}  ({hex(unchecked)})  <- আগের bug, silent wrap")

print("\n  checked_alloc_size() চেষ্টা করা হচ্ছে...")
try:
    size = checked_alloc_size(COUNT, ELEMENT_SIZE)
    print(f"  checked_alloc_size() -> {size}")
except AllocationTooLargeError as e:
    print(f"  BLOCKED: {e}")

print("\n== স্বাভাবিক, বৈধ input-এ checked_alloc_size() স্বাভাবিকভাবেই কাজ করে ==")
normal_count = 100
size = checked_alloc_size(normal_count, ELEMENT_SIZE)
print(f"  count={normal_count}, element_size={ELEMENT_SIZE} -> checked_alloc_size() = {size} byte")

প্রকৃত চালিয়ে পাওয়া output (deterministic — হুবহু এটাই আসবে):

== একই attacker input, দুইটা ভিন্ন size-calculation policy ==
  count = 1,073,741,825 (0x40000001),  element_size = 4

  unchecked_alloc_size() -> 4  (0x4)  <- আগের bug, silent wrap

  checked_alloc_size() চেষ্টা করা হচ্ছে...
  BLOCKED: count(1073741825) * element_size(4) = 4294967300 > 32-bit সর্বোচ্চ ধারণক্ষমতা (4294967295) -- allocation প্রত্যাখ্যাত

== স্বাভাবিক, বৈধ input-এ checked_alloc_size() স্বাভাবিকভাবেই কাজ করে ==
  count=100, element_size=4 -> checked_alloc_size() = 400 byte

unchecked_alloc_size() আগের মতোই নীরবে 4 ফেরত দিয়েছে — hood-সেকশনের পুরো corruption-চেইন এখান থেকেই শুরু হয়েছিল। checked_alloc_size() একই input-এ exception raise করেছে — allocation নিজেই কখনো ঘটেনি, তাই আর undersized buffer-ও তৈরি হয়নি, আর তার ফলে ধাপ ৩-৫-এর corruption-ও ঘটার সুযোগ পায়নি। স্বাভাবিক input-এ (count=100) কোনো false-positive নেই — মূল নীতি: truncate করার আগে চেক করুন, পরে না — ঠিক prerequisite লেসনের “before, not after” নীতিরই allocation-size সংস্করণ।

নিজে বাড়ান:

  1. checked_alloc_size-এর পাশাপাশি একটা saturating_alloc_size লিখুন যা overflow হলে ধারণক্ষমতার সর্বোচ্চ মান (2ᵇⁱᵗˢ - 1) ফেরত দেয় — তারপর allocation-context-এ কেন এটা আসলে checked-এর চেয়ে কম নিরাপদ তা যুক্তি দিন (ইঙ্গিত: malloc(4294967295) কী করবে — সফল হবে, নাকি ব্যর্থ হবে, নাকি অন্য কোনো সমস্যা তৈরি করবে?)
  2. Rust-এ একই তুলনা করুন — count.checked_mul(element_size) একটা Option<usize> ফেরত দেয়, count.saturating_mul(element_size) clamp করে, আর সাধারণ count * element_size debug build-এ panic করে, release build-এ silently wrap করে (overflow-checks কম্পাইল-flag নিয়ন্ত্রণ করে) — prerequisite লেসনের checked_add/wrapping_add/saturating_add তুলনার ঠিক multiplication-সংস্করণ
  3. element_size-কে যদি runtime-এ নির্ধারণ করা হয় (যেমন একটা struct-এর আকার, যেটা নিজেও কোনো configuration থেকে আসে), তাহলে “consistent type sizing” নীতিটা ঠিক কী বলে — count আর element_size দুটোই কি একই bit-width-এর হওয়া উচিত? দুটোই কি একই signedness-এর হওয়া উচিত? যুক্তি দিন কেন মিশ্রিত সাইজের (যেমন int32_t count, size_t element_size) arithmetic নিজেই একটা ঝুঁকি

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

বাস্তব জগতে — যখন একটা wraparound সত্যিই root shell দিয়েছে

CVE-2021-3156 (“Baron Samedit”) — sudo-র heap buffer overflow। ২০২১ সালের জানুয়ারিতে Qualys-এর প্রকাশিত এই advisory প্রায় এক দশক পুরনো (২০১১ সাল থেকে চলে আসা) একটা বাগ প্রকাশ করে sudo-তে — একটা প্রায় সব প্রধান Linux distribution-এ default install-এ থাকা, root-privilege-এর জন্য দায়িত্বপ্রাপ্ত প্রোগ্রাম। মূল সমস্যা: sudo যখন sudoedit-এর মতো কমান্ড-লাইন argument-এ backslash escape করে, একটা buffer-size গণনার মধ্যে একটা off-by-one/underflow ধরনের integer bug ছিল — কিছু নির্দিষ্ট আর্গুমেন্ট প্যাটার্নে (বিশেষভাবে গঠিত backslash sequence) গণনাটা ভুল, undersized একটা বাফার allocate করত, তারপর প্রকৃত argument কপি করার সময় সেই বাফারের সীমা ছাড়িয়ে হিপ-এ লিখত — ঠিক আজকের hood-সেকশনের প্যাটার্নের আকৃতি: একটা size-calculation ভুল, তারপর একটা পরের write সেই ভুলকে বিশ্বাস করে buffer ছাপিয়ে যায়। যেহেতু sudo root হিসেবে চলে, একটা সফল heap corruption সরাসরি local privilege escalation-এ (unprivileged user থেকে root) রূপ নিতে পারত — কোনো password লাগত না, কারণ বাগটা sudo-র নিজস্ব argument-parsing-এ, authentication logic-এর পরে না, তার আগে।

CWE-190 এবং CWE-191 — কেন দুইটা আলাদা তালিকাভুক্ত। MITRE-এর শ্রেণিবিন্যাসে overflow (মান খুব বড় হয়ে wrap করা, CWE-190) আর underflow (মান শূন্যের নিচে গিয়ে wrap করা, CWE-191) আলাদা এন্ট্রি — কিন্তু আজকের লেসনের দুইটা প্যাটার্নই দেখায় বাস্তবে এই দুইটা প্রায়ই একসাথে ঘটে একটা একক bug-এ: hood-সেকশনের multiplication CWE-190-এর ক্লাসিক উদাহরণ, experiment-সেকশনের signed-negative-থেকে-huge-unsigned রূপান্তর CWE-191-এর আত্মীয়। উভয় CWE-ই স্পষ্টভাবে “resultant weakness”-এর তালিকায় buffer overflow (CWE-119/CWE-787) উল্লেখ করে — অর্থাৎ MITRE নিজেই এই combo-বাগ প্যাটার্নটাকে একটা প্রথম-শ্রেণির vulnerability chain হিসেবে স্বীকৃতি দেয়।

Language-level fix — Rust-এর overflow-checked arithmetic। আগের memory-safety লেসনগুলোতে (use-after-free/double-free) আমরা তিন-স্তরের mitigation ফ্রেমওয়ার্ক দেখেছিলাম — programming discipline, hardened runtime, আর language-level structural fix। Integer overflow-এর জন্য Rust-এর উত্তর ঠিক তৃতীয় স্তরের: debug build-এ প্রতিটা arithmetic operation ডিফল্টভাবে overflow-checked (overflow হলে panic — একটা loud, নিয়ন্ত্রিত ব্যর্থতা, silent wrap না), আর release build-এ (overflow-checks = false ডিফল্ট) wrap করে, কিন্তু checked_mul/saturating_mul/ wrapping_mul-এর মতো explicit, নাম-দেওয়া মেথড দিয়ে প্রোগ্রামারকে প্রতিটা call-site-এ সচেতনভাবে একটা policy বেছে নিতে বাধ্য করে — বেছে না নিলে প্লেইন * অপারেটর ব্যবহার করলে অন্তত debug-এ বাগটা ধরা পড়বে। এটা বাগটা “অসম্ভব” করে না (release build-এ এখনও wrap সম্ভব যদি প্রোগ্রামার প্লেইন অপারেটর বেছে নেয়), কিন্তু এটাকে নীরব থেকে দৃশ্যমান করে দেয় — ঠিক আগের লেসনগুলোর generation-guard আর hardened-allocator মিটিগেশনের একই দর্শন, এবার arithmetic-এর স্তরে।

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

“Integer overflow-জনিত allocation বাগ শুধু পুরনো, 32-bit সিস্টেমে ঘটে — আধুনিক 64-bit সিস্টেমে address space এত বিশাল যে এই সমস্যা আর নেই।”

Address space-এর আকার আর একটা নির্দিষ্ট variable-এর bit-width — দুইটা সম্পূর্ণ ভিন্ন জিনিস। একটা 64-bit সিস্টেমেও প্রোগ্রামের নিজের কোডে count, size, বা element_size প্রায়ই এখনও int বা uint32_t টাইপে রাখা হয় — protocol-এর ওয়্যার ফরম্যাট, ফাইল-ফরম্যাটের হেডার, বা পুরনো API signature-এর কারণে। Hood-সেকশনের ডেমো ইচ্ছাকৃতভাবে MASK32 ব্যবহার করেছে ঠিক এই কারণে — এটা “পুরনো হার্ডওয়্যার”-এর গল্প না, এটা “এই নির্দিষ্ট variable-এর ঘোষিত টাইপ কত চওড়া” তার গল্প, যা 64-bit সিস্টেমেও প্রায়ই 32-bit থেকে যায়।

“একটা length-check-এ শুধু upper bound (requested_len > max_len) verify করলেই যথেষ্ট — negative আলাদা করে চেক করার দরকার নেই, কারণ length তো ধনাত্মক হওয়ারই কথা।”

“হওয়ার কথা” আর “সবসময় হয়” — দুইটা আলাদা। Experiment-সেকশন সরাসরি এর বিরুদ্ধে প্রমাণ: signed domain-এ -1 > 1024 মিথ্যা, তাই upper-bound-only চেক -1-কে “বৈধ” হিসেবে গ্রহণ করে নেয় — অথচ সেই একই -1, unsigned size_t-এ পৌঁছালে, upper bound-এর বহু গুণ বড় একটা সংখ্যা হয়ে যায়। একটা সম্পূর্ণ length-check-এ সবসময় দুইটা দিক লাগে: নিচের সীমা (ঋণাত্মক না) আর ওপরের সীমা (buffer capacity-র মধ্যে) — একটা বাদ দিলে পুরো চেকটাই এক দিক থেকে খোলা থেকে যায়।

“malloc() যদি একটা non-NULL pointer সফলভাবে ফেরত দেয়, তার মানে size calculation-টা নিজেই সঠিক ছিল।”

malloc() শুধু এইটুকু গ্যারান্টি দেয়: “আমাকে যে সংখ্যাটা দেওয়া হয়েছে, সেই সংখ্যক byte-এর একটা ব্যবহারযোগ্য region আমি পেয়েছি (বা NULL ফেরত দিয়েছি যদি না পাই)”। এটা কখনোই যাচাই করে না সেই সংখ্যাটা প্রোগ্রামারের আসল উদ্দেশ্যের সাথে মেলে কিনা। উদাহরণ-সেকশনের malloc(0) কেসটা এটা স্পষ্ট করে — একটা non-NULL pointer সফলভাবে ফেরত আসতে পারে, অথচ ব্যবহারযোগ্য জায়গা শূন্য। “malloc() ব্যর্থ হয়নি” আর “size calculation সঠিক ছিল” — এই দুইটা সম্পূর্ণ স্বাধীন প্রশ্ন, একটার উত্তর অন্যটা প্রমাণ করে না।

“Saturating arithmetic সবসময় নিরাপদ বিকল্প — overflow হলে সর্বোচ্চ মানে clamp করে দিলেই যথেষ্ট সুরক্ষা পাওয়া যায়।”

Build-সেকশনের “নিজে বাড়ান” প্রশ্ন ১ ঠিক এই ভুল-ধারণার ওপর — saturating policy prerequisite লেসনে pixel/audio-এর মতো context-এ যথাযথ ছিল, কারণ সেখানে “সর্বোচ্চ মান”-ই কাম্য ফলাফল। কিন্তু একটা allocation-size context-এ saturate করা মানে malloc()-কে বলা “প্রায় 4 গিগাবাইট (বা তার বেশি) বরাদ্দ করো” — যা হয় allocation নিজেই ব্যর্থ করবে (একটা ভিন্ন, কিন্তু এখনও handle করতে হবে এমন failure mode), অথবা সফল হলে একটা বিশাল, অকেজো allocation দিয়ে memory exhaustion/denial-of-service-এর দরজা খুলে দেবে। Allocation-size-এর জন্য সঠিক policy সবসময় checked (প্রত্যাখ্যান করা), saturate করা না — ঠিক prerequisite লেসনের payment-system প্রশ্নের মতোই যুক্তি, ভিন্ন domain-এ প্রয়োগ করা।

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

1

Allocation-size overflow প্যাটার্নে ঠিক কোন তিনটা ধাপ ক্রমানুসারে ঘটে — count * element_size থেকে শুরু করে কোন পর্যন্ত?

স্মরণ

১) size = count * element_size হিসাব হয়, যেখানে count attacker-নিয়ন্ত্রিত; ২) এই গুণফল representation-এর সীমা (যেমন 32-bit size_t) ছাড়িয়ে গেলে wrap করে একটা ছোট মান হয়ে যায়, আর সেই wrap-হওয়া মানটাই malloc()-এর কাছে পৌঁছায়, ফলে একটা undersized buffer বরাদ্দ হয়; ৩) পরের write-loop মূল, unwrapped count-এর ওপর ভিত্তি করে লিখতে থাকে (কারণ সেই লুপ জানে না যে buffer-এর আসল সাইজ ছোট হয়ে গেছে), ফলে buffer-এর সীমা ছাড়িয়ে adjacent heap মেমরি corrupt হয়।

2

if (requested_len > max_len) reject(); — এই চেকটা কেন requested_len signed হলে negative input আটকাতে ব্যর্থ হয়, অথচ একই bit-pattern পরে memcpy-তে পৌঁছে বিপজ্জনক হয়ে ওঠে? দুইটা ঘটনার মধ্যেকার সংযোগটা ব্যাখ্যা করুন।

যুক্তি

চেকটা signed domain-এ মূল্যায়ন হয় — signed arithmetic-এর সংজ্ঞা অনুযায়ী যেকোনো ঋণাত্মক সংখ্যা যেকোনো ধনাত্মক সংখ্যার চেয়ে ছোট, তাই -1 > 1024 মিথ্যা, চেক পাস করে যায়। কিন্তু memcpy-র সিগনেচারে তৃতীয় প্যারামিটার size_t, যা unsigned — সেই একই ৩২-বিট bit-pattern (0xFFFFFFFF) এবার unsigned নিয়মে পড়া হয়, যেখানে এটা প্রায় ৪.৩ বিলিয়ন। সংযোগটা হলো: কোনো নতুন ডেটা তৈরি হয়নি, শুধু একই bit-pattern-কে দুই ভিন্ন জায়গায় দুই ভিন্ন টাইপ-নিয়মে “পড়া” হয়েছে — একবার signed comparison-এ, একবার unsigned function-parameter-এ — আর এই দুই পাঠের মধ্যেকার অসঙ্গতিটাই বাগের মূল কারণ।

3

count = 0x60000000, element_size = 4 — হাতে হিসাব করুন সত্যিকারের গুণফল কত, আর ৩২-বিটে mask করলে size কী মান পাবে। এই উদাহরণটা কেন আগের উদাহরণগুলোর (যেখানে wrap-হওয়া মান ছোট ছিল) থেকে ভিন্ন একটা শিক্ষা দেয়?

প্রয়োগ

count * element_size = 0x60000000 × 4 = 0x180000000, যা 2³³-এর কাছাকাছি একটা সংখ্যা এবং ৩৩ বিট লাগে সঠিকভাবে রাখতে। 32-bit-এ mask করলে (& 0xFFFFFFFF) সবচেয়ে উঁচু বিটটা বাদ পড়ে যায়: 0x180000000 & 0xFFFFFFFF = 0x80000000, দশমিকে 2,147,483,648। লক্ষ্য করুন এই wrap-হওয়া মানটা নিজেই বেশ বড় (ছোট না) — এই উদাহরণটা দেখায় সব overflow ছোট, “আকর্ষণীয়ভাবে বিপজ্জনক” মানে নামে না; মাঝে মাঝে wrap-হওয়া মান নিজেও বড় থাকে, কিন্তু তখনও সেটা প্রোগ্রামারের উদ্দেশ্যের সংখ্যা (আসল গুণফল) থেকে সম্পূর্ণ আলাদা একটা সংখ্যা — যা পরের কোড যদি “correct” ধরে নেয়, ভুল ধরে নেয়।

4

Hood-সেকশনের ডেমোতে malloc() সঠিকভাবে 4 byte বরাদ্দ করেছে — অর্থাৎ allocator নিজে কোনো ভুল করেনি। তাহলে দোষটা ঠিক কোথায়, আর কেন এটা “allocator-এর বাগ” না বলে “caller-এর বাগ” বলা উচিত?

যুক্তি

malloc(size)-কে যে size-টা দেওয়া হয়েছিল সেটাই ছিল ভুল — malloc() নিজে সেই সংখ্যাটাকে বিশ্বাসযোগ্য হিসেবে ধরে নিয়ে ঠিক কাজ করেছে, ঠিক তার কন্ট্রাক্ট অনুযায়ী। ভুলটা malloc()-এর আগে ঘটেছে (size calculation-এ) আর malloc()-এর পরে প্রকাশ পেয়েছে (write loop-এ)। malloc()-এর কোনো উপায় নেই জানার যে caller একটা wrap-হওয়া সংখ্যা পাঠিয়েছে বনাম একটা সত্যিকারের ছোট allocation চেয়েছে — দুইটা দৃশ্যেই সে একই signature (একটা integer) পায়। এই কারণেই fix-টাও malloc()-এর স্তরে না, বরং caller-এর size-calculation স্তরে (আজকের build-সেকশনের checked_alloc_size) বসাতে হয়।

5

Build-সেকশনের checked_alloc_size(count, element_size, bits)-এ count = 300, element_size = 20, bits = 16 দিয়ে কল করলে ফাংশনটা কী করবে? হাতে হিসাব করে দেখান।

প্রয়োগ

true_product = 300 × 20 = 6000। bits = 16-এর জন্য max_value = 2¹⁶ - 1 = 65535। যেহেতু 6000 ≤ 65535, শর্ত (true_product > max_value) মিথ্যা, তাই কোনো exception raise হবে না — ফাংশনটা স্বাভাবিকভাবে 6000 রিটার্ন করবে। এটা দেখায় checked_alloc_size শুধু overflow-এর ক্ষেত্রেই ব্যর্থ হয়, বৈধ input-এ এটা প্লেইন multiplication-এর মতোই স্বচ্ছভাবে কাজ করে — কোনো false-positive নেই।

6

আপনি একটা image-decoding লাইব্রেরি রিভিউ করছেন যেখানে বহু জায়গায় buffer_size = width * height * bytes_per_pixel-জাতীয় প্যাটার্ন আছে, আর width/height দুটোই ফাইলের হেডার থেকে সরাসরি পড়া, কোনো upper-bound ছাড়াই। পুরো কোডবেস একবারে rewrite করা সম্ভব না। কীভাবে এই bug-ক্লাস পদ্ধতিগতভাবে খুঁজবেন, আর ঠিক করার জন্য কী অগ্রাধিকার দেবেন?

ডিজাইন

খুঁজে বের করা: প্রথমে grep/static-analysis দিয়ে সব malloc/calloc-এর কল-সাইট চিহ্নিত করুন যেখানে size-expression-এ একাধিক variable-এর গুণফল আছে (বিশেষভাবে যেখানে কোনো variable সরাসরি ফাইল/নেটওয়ার্ক input থেকে এসেছে) — __builtin_mul_overflow/ckd_mul/checked_mul-এর মতো একটা checked-arithmetic wrapper না থাকলে সেই প্রতিটাই সন্দেহভাজন। Fuzz testing (বিশেষত libFuzzer/AFL, ইচ্ছাকৃতভাবে width/height-কে 2³²-এর কাছাকাছি মানে ঠেলে) এই ক্লাসের বাগ খুঁজতে বিশেষভাবে কার্যকর, কারণ এই বাগ-প্যাটার্নটা একটা নির্দিষ্ট, সংকীর্ণ ইনপুট-রেঞ্জে ট্রিগার হয় — ঠিক hood-সেকশনের 0x40000001-এর মতো “কাছাকাছি” মান দরকার।

স্বল্পমেয়াদী fix: সবচেয়ে বেশি untrusted-input-touching (ফাইল-হেডার পড়ার পরপরই) calculation-গুলোতে width/height-এর ওপর একটা explicit, application-নির্দিষ্ট upper bound বসানো (যেমন “কোনো বাস্তবসম্মত ছবি ৬৫৫৩৬ পিক্সেলের বেশি চওড়া না”) — এটা আসল fix না, কিন্তু attack surface তাৎক্ষণিকভাবে সংকুচিত করে। পাশাপাশি প্রতিটা multiplication-কে checked-arithmetic wrapper-এ পরিবর্তন করা, ঠিক আজকের build-সেকশনের নীতি মেনে।

দীর্ঘমেয়াদী (structural): পুরো size-calculation logic-টাকে একটা কেন্দ্রীভূত, একবার-লেখা, সবসময়-checked হেল্পার ফাংশনে সরিয়ে আনা, যাতে প্রতিটা কল-সাইটে প্রোগ্রামারকে আলাদা করে মনে রাখতে না হয় — আর যেখানে সম্ভব, image-decoding-এর মতো সরাসরি untrusted-input parse করা অংশ memory-safe ভাষায় (Rust, বা একটা audited, checked-arithmetic-নির্ভর লাইব্রেরি) rewrite করার পরিকল্পনা করা, ঠিক আগের memory-safety লেসনগুলোর একই “attack surface অনুযায়ী অগ্রাধিকার” নীতি অনুসরণ করে।

এরপর কী

এরপর কী

চারটা লেসন ধরে আমরা মেমরি-স্তরে থেকেছি — প্রথমে একটা স্থানিক ভুল (buffer overflow, ভুল জায়গায় লেখা), তারপর একটা সময়গত ভুল (use-after-free আর double-free, ভুল সময়ে ব্যবহার করা), আর আজ একটা তৃতীয় ধরনের ভুল — মেমরিতে পৌঁছানোর আগেই, একটা সংখ্যার হিসাবে ভুল হয়ে যাওয়া, যা পরে malloc() আর memcpy()-কে বিভ্রান্ত করে ঠিক আগের দুই লেসনের একই পরিণতিতে (heap corruption, control-flow-এর ঝুঁকি) নিয়ে যায়। তিনটাই আলাদা মেকানিজম, কিন্তু একই root cause ভাগ করে — C/C++-এর কাছে “এই সংখ্যা বা pointer বৈধ কিনা” যাচাই করার কোনো বাধ্যতামূলক, ভাষা-স্তরের ব্যবস্থা নেই; প্রতিটা যাচাই প্রোগ্রামারের হাতে-লেখা দায়িত্ব, প্রতিটা কল-সাইটে, প্রতিবার।

কিন্তু এই চারটা লেসনের প্রতিটা বাগ-ই একটা সাধারণ বৈশিষ্ট্য ভাগ করে: প্রতিটা exploit-এর জন্য attacker-কে মেমরি নিজে corrupt করতে হয়েছে — একটা buffer ছাপিয়ে লেখা, একটা dangling pointer dereference করা, বা একটা undersized allocation-এর সীমা ছাড়ানো। এখন থেকে আমরা এমন একটা বাগ-শ্রেণির দিকে যাচ্ছি যেখানে কোনো মেমরি corruption লাগেই না — প্রোগ্রাম মেমরি-সেফটির দিক থেকে সম্পূর্ণ নিখুঁত থাকতে পারে, তবু ভয়ানকভাবে exploitable হতে পারে।

আমরা চার লেসন ধরে মেমরি-লেয়ারে থেকেছি। এখন আমরা এক লেয়ার ওপরে ফিরে যাচ্ছি — এমন সব বাগের দিকে, যেখানে প্রোগ্রামের কোড আর তার নিজের হাতে পাওয়া ডেটার মধ্যে পার্থক্য করার কোনো উপায়ই থাকে না। পরের লেসনে: injection attacks।

আরও পড়ুন

  • CVE-2021-3156: Heap-Based Buffer Overflow in Sudo ("Baron Samedit") — Qualys Security Advisory · একটা integer-overflow/underflow (command-line argument-এর backslash-escaping গণনায়) কীভাবে sudo-কে একটা undersized heap buffer allocate করতে বাধ্য করে, তারপর সেই একই buffer overflow করায় — আজকের hood/experiment অংশের প্যাটার্নের একদম বাস্তব সংস্করণ
  • CWE-190: Integer Overflow or Wraparound — MITRE — Common Weakness Enumeration · Allocation-size overflow-এর আনুষ্ঠানিক শ্রেণিবিন্যাস — wraparound-এর ফলে একটা buffer প্রত্যাশার চেয়ে ছোট allocate হওয়া স্পষ্টভাবে একটা উদাহরণ হিসেবে তালিকাভুক্ত
  • CWE-191: Integer Underflow (Wrap or Wraparound) — MITRE — Common Weakness Enumeration · Signed/unsigned confusion-এর ঘনিষ্ঠ আত্মীয় — ঋণাত্মক দিকে wrap করা একটা মান unsigned প্যারামিটারে পৌঁছে বিশাল হয়ে যাওয়ার শ্রেণিবিন্যাস