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 আর্কের শেষ অধ্যায় হিসেবে।
আগে এটা বুঝি
তিনটা লেসন আগে আমরা 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 Overflow | Signed/Unsigned Confusion | |
|---|---|---|
| কোথায় ভাঙে | multiplication (count * element_size) | comparison (signed check বনাম unsigned use) |
| wrap-এর দিক | উপরের দিকে (বড় থেকে ছোট, mod 2ⁿ) | নিচের দিকে (ঋণাত্মক থেকে বিশাল ধনাত্মক, reinterpret) |
| ভুক্তভোগী call | malloc(size) — ছোট buffer পায় | memcpy(dst, src, len) — বিশাল length পায় |
| CWE | CWE-190 | CWE-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” ছাড়া আর কিছুই জানা ছিল না।
- attacker inputএকটা "count" ফিল্ড, সম্পূর্ণ attacker-নিয়ন্ত্রিত (protocol/file-format থেকে আসা)
- size calculationcount * element_size — গাণিতিকভাবে সঠিক, কিন্তু size_t-এর ধারণক্ষমতা ছাড়িয়ে যায়
- representation (integer-overflow লেসনের বিষয়)32-bit-এ mod 2³² wrap হয়ে একটা ছোট, "বৈধ দেখতে" মান তৈরি হয়
- malloc()সেই ছোট মানকেই সত্য ধরে নিয়ে একটা বাস্তব, undersized buffer বরাদ্দ করে
- write loopমূল, unwrapped count-এর ওপর ভিত্তি করে লিখতে থাকে — buf-এর প্রকৃত আকার সম্পর্কে কিছুই জানে না
উদাহরণ
হাতে হিসাব — কোন (count, element_size) জোড়া wrap করে
Hood-সেকশনের একটা মাত্র জোড়া (0x40000001, 4) না, বরং আরও কয়েকটা
বাস্তবসম্মত জোড়া হাতে হিসাব করলে বোঝা যায় এই বাগ কতটা সহজে ট্রিগার হয় —
কোনো “exotic” ইনপুট লাগে না, শুধু গুণফল 2³²-এর একটা multiple-এর কাছাকাছি
পড়লেই যথেষ্ট:
count | element_size | সত্যিকারের গুণফল | 32-bit-এ wrap-হওয়া size |
|---|---|---|---|
16 | 4 | 64 | 64 (কোনো overflow নেই — স্বাভাবিক) |
0x40000000 (১,০৭৩,৭৪১,৮২৪) | 4 | 4,294,967,296 (2³²) | 0 |
0x40000001 (১,০৭৩,৭৪১,৮২৫) | 4 | 4,294,967,300 | 4 |
0x80000000 (২,১৪৭,৪৮৩,৬৪৮) | 2 | 4,294,967,296 (2³²) | 0 |
0x20000000 (৫৩৬,৮৭০,৯১২) | 8 | 4,294,967,296 (2³²) | 0 |
0xFFFFFFFF (৪,২৯৪,৯৬৭,২৯৫) | 1 | 4,294,967,295 | 4,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()-এর কাছে এটাই এখন "সত্য")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দুইটা diagram-ই একই সত্য দুইভাবে দেখাচ্ছে: প্রথমটায় একটা বিট হারিয়ে যাচ্ছে (গণনার ফলাফল storage-এ আঁটেনি), দ্বিতীয়টায় কোনো বিট হারায়নি, শুধু একই বিট-প্যাটার্ন দুইটা ভিন্ন নিয়মে দুইবার পড়া হয়েছে। দুইটাই integer-overflow লেসনের representation-ভাষায় বলা যায়, কিন্তু পরিণতি একই — একটা মেমরি-অপারেশন যে সংখ্যাটা বিশ্বাস করছে, সেটা প্রোগ্রামারের ইচ্ছার সংখ্যা না।
নিজে চালিয়ে দেখুন
Signed length-check bypass — সংখ্যায়, memcpy পর্যন্ত
# 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 হয়ে যায়।
নিজে বানান
checked_alloc_size — multiplication-কে wrap করতে না দেওয়া
- একটা checked_alloc_size(count, element_size, bits) ফাংশন লিখুন যা প্রকৃত গুণফল একটা wider domain-এ (Python-এর arbitrary-precision int) হিসাব করে
- সেই প্রকৃত গুণফলকে bits-bit সর্বোচ্চ ধারণক্ষমতার সাথে তুলনা করুন -- truncate করার আগেই
- ধারণক্ষমতা ছাড়ালে একটা exception raise করুন, silent wrap হতে দেবেন না
- হুড-সেকশনের ঠিক একই attacker input (count=0x40000001, element_size=4) দিয়ে দেখান checked ভার্সন block করে যেখানে unchecked ভার্সন silently 4 রিটার্ন করেছিল
- একটা স্বাভাবিক, ছোট 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 byteunchecked_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 সংস্করণ।
নিজে বাড়ান:
checked_alloc_size-এর পাশাপাশি একটাsaturating_alloc_sizeলিখুন যা overflow হলে ধারণক্ষমতার সর্বোচ্চ মান (2ᵇⁱᵗˢ - 1) ফেরত দেয় — তারপর allocation-context-এ কেন এটা আসলেchecked-এর চেয়ে কম নিরাপদ তা যুক্তি দিন (ইঙ্গিত:malloc(4294967295)কী করবে — সফল হবে, নাকি ব্যর্থ হবে, নাকি অন্য কোনো সমস্যা তৈরি করবে?)- Rust-এ একই তুলনা করুন —
count.checked_mul(element_size)একটাOption<usize>ফেরত দেয়,count.saturating_mul(element_size)clamp করে, আর সাধারণcount * element_sizedebug build-এ panic করে, release build-এ silently wrap করে (overflow-checksকম্পাইল-flag নিয়ন্ত্রণ করে) — prerequisite লেসনেরchecked_add/wrapping_add/saturating_addতুলনার ঠিক multiplication-সংস্করণ 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-এ প্রয়োগ করা।
বুঝেছেন কি না দেখুন
1Allocation-size overflow প্যাটার্নে ঠিক কোন তিনটা ধাপ ক্রমানুসারে ঘটে —
count * element_size থেকে শুরু করে কোন পর্যন্ত?
স্মরণ
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 হয়।
2if (requested_len > max_len) reject(); — এই চেকটা কেন requested_len
signed হলে negative input আটকাতে ব্যর্থ হয়, অথচ একই bit-pattern পরে
memcpy-তে পৌঁছে বিপজ্জনক হয়ে ওঠে? দুইটা ঘটনার মধ্যেকার সংযোগটা ব্যাখ্যা
করুন।
যুক্তি
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-এ — আর এই দুই পাঠের মধ্যেকার অসঙ্গতিটাই বাগের মূল
কারণ।
3count = 0x60000000, element_size = 4 — হাতে হিসাব করুন সত্যিকারের
গুণফল কত, আর ৩২-বিটে mask করলে size কী মান পাবে। এই উদাহরণটা কেন
আগের উদাহরণগুলোর (যেখানে wrap-হওয়া মান ছোট ছিল) থেকে ভিন্ন একটা শিক্ষা
দেয়?
প্রয়োগ
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” ধরে নেয়, ভুল ধরে নেয়।
4Hood-সেকশনের ডেমোতে malloc() সঠিকভাবে 4 byte বরাদ্দ করেছে — অর্থাৎ
allocator নিজে কোনো ভুল করেনি। তাহলে দোষটা ঠিক কোথায়, আর কেন এটা
“allocator-এর বাগ” না বলে “caller-এর বাগ” বলা উচিত?
যুক্তি
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)
বসাতে হয়।
5Build-সেকশনের checked_alloc_size(count, element_size, bits)-এ
count = 300, element_size = 20, bits = 16 দিয়ে কল করলে ফাংশনটা কী
করবে? হাতে হিসাব করে দেখান।
প্রয়োগ
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-ক্লাস
পদ্ধতিগতভাবে খুঁজবেন, আর ঠিক করার জন্য কী অগ্রাধিকার দেবেন?
ডিজাইন
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 প্যারামিটারে পৌঁছে বিশাল হয়ে যাওয়ার শ্রেণিবিন্যাস