Use-After-Free ও Double-Free — মৃত মেমরির উপর দাঁড়িয়ে থাকা পয়েন্টার
Use-After-Free and Double-Free
free() করা মেমরি খালি হয় না — শুধু free-list-এ ফিরে যায়, ডেটা আর pointer দুটোই অক্ষত থাকে; এই একটা ফাঁক use-after-free-কে সম্ভব করে, আর double-free সেই একই free-list-কে corrupt করে malloc-কে attacker-নির্ধারিত arbitrary address ফেরত দিতে বাধ্য করতে পারে — আজ এই দুটোই browser আর kernel-এর সবচেয়ে বড় vulnerability class।
আগে এটা বুঝি
আগের লেসনে buffer overflow দেখেছেন — একটা memory region-এর সীমানা ভাঙা, বেশি লেখা বা বেশি পড়া হয়ে গেছে যেখানে হওয়ার কথা ছিল না। সেটা একটা spatial সমস্যা — ভুল জায়গা। আজকের বাগ-জোড়া সম্পূর্ণ ভিন্ন মাত্রার: এখানে জায়গাটা ঠিকই ছিল, ভুলটা সময়ে — একটা pointer এমন মেমরিকে দেখাচ্ছে যেটার “জীবনকাল” ইতিমধ্যে শেষ হয়ে গেছে। এই পার্থক্যটা মনে রাখা জরুরি, কারণ পুরো লেসনটাই এই একটা প্রশ্নের ওপর দাঁড়িয়ে:
spatial bug : সঠিক pointer, ভুল offset (buffer overflow)
temporal bug : সঠিক offset, ভুল সময়ে ব্যবহার (use-after-free, আজকের বিষয়)Use-after-free (UAF) ঘটে যখন কোনো প্রোগ্রাম একটা free() করা মেমরি
region-কে তার পুরনো pointer দিয়ে আবার dereference করে — যেন সেটা এখনো
বৈধ। Double-free ঘটে যখন একই pointer-এ দুইবার free() ডাকা হয়, কোনো
মাঝখানের নতুন malloc() ছাড়াই। দুটোই একই মৌলিক কারণ থেকে জন্মায় — একটা
allocator-এর “এই মেমরি এখন কার” বই-রক্ষণ (bookkeeping) আর প্রোগ্রামের নিজের
“আমার কাছে এখনো একটা pointer আছে” বিশ্বাস — এই দুইটা রেকর্ড সিঙ্কে থাকার
কোনো গ্যারান্টি C/C++-এর ভাষা-স্তরে নেই। আজকের লেসনে দেখব ঠিক কীভাবে এই
সিঙ্ক ভাঙে, আর কেন এই দুটো বাগ আজ browser আর kernel security-র সবচেয়ে ঘন
আলোচিত vulnerability class।
মূল ধারণা
Allocator recap — এক লাইনে যা আগের লেসনে ধাপে ধাপে দেখা হয়েছে
Memory Allocation লেসনের ভাষায়: heap-কে ছোট ছোট chunk-এ ভাগ করা হয়;
প্রতিটা chunk-এর একটা header থাকে (আকার, in-use/free flag); free()
ডাকলে chunk-টা কোনো object বরাদ্দ থেকে সরে গিয়ে একটা free list-এ
(বা bin-এ) ফিরে যায়, যাতে ভবিষ্যতের কোনো malloc() কল সেটা পুনর্ব্যবহার
করতে পারে। আজকের পুরো লেসনের জন্য শুধু এই একটা বাক্যই যথেষ্ট প্রাসঙ্গিক:
Use-after-free — সংজ্ঞা আর precise mechanism
একটা pointer p-কে dangling pointer বলা হয় যখন free(p) কল হয়ে গেছে
কিন্তু p variable-টা এখনো (পুরনো, এখন-অবৈধ) সেই ঠিকানাটাই ধরে আছে।
Use-after-free ঘটে ঠিক তখন, যখন এই dangling pointer-কে dereference
করা হয় — পড়া (*p), লেখা (*p = ...), বা কোনো method/function pointer
কল (p->vtable->fn())। ফলাফল নির্ভর করে সেই সময় ওই ঠিকানায় কে বাস
করছে তার ওপর:
| ঠিক ওই মুহূর্তে chunk-টা কী অবস্থায় | dereference-এর ফলাফল |
|---|---|
| এখনো free list-এ, কেউ নতুন allocate করেনি | পুরনো ডেটা এখনো পড়া যায় — প্রায়ই নিরীহ দেখায়, কিন্তু undefined behavior |
| অন্য একটা নতুন, ভিন্ন object সেই একই chunk পেয়ে গেছে | type confusion — পুরনো pointer-এর টাইপ দিয়ে নতুন object-এর raw bytes পড়া/লেখা হচ্ছে |
| attacker ইচ্ছাকৃতভাবে সেই chunk-এ নিজের ডেটা বসিয়েছে | নিয়ন্ত্রিত info-leak বা corruption — আজকের experiment-এর বিষয় |
chunk-টা free()-এর পরে allocator OS-কে ফেরত দিয়েছে | সাধারণত সরাসরি crash (segfault) — সবচেয়ে কম বিপজ্জনক outcome, কারণ চুপচাপ কাজ করে না |
লক্ষ্য করুন — সবচেয়ে বিপজ্জনক সারিটাই (তৃতীয়টা) সবচেয়ে “শান্ত”: কোনো crash নেই, প্রোগ্রাম স্বাভাবিকভাবেই চলতে থাকে, শুধু ভুল ডেটা নিয়ে। এটাই UAF-কে buffer-overflow-এর চেয়েও প্রায়ই বিপজ্জনক করে তোলে — crash একটা সংকেত, silent corruption সংকেতহীন।
Double-free — সংজ্ঞা আর কেন এটা আলাদাভাবে বিপজ্জনক
Double-free আসলে use-after-free-এরই একটা বিশেষ রূপ — dereference না,
বরং allocator-এর নিজের metadata-কেই dereference করা। free(p) দুইবার
ডাকলে (মাঝখানে কোনো নতুন malloc() p-কে পুনরায় বরাদ্দ না করলে), দ্বিতীয়
free() কল একটা chunk-কে free-list-এ আবার ঢোকানোর চেষ্টা করে, যেটা
ইতিমধ্যে সেখানে আছে। বেশিরভাগ allocator-এর free-list একটা linked
structure (glibc-র fastbin-এ singly-linked, fd পয়েন্টার প্রতিটা free
chunk-এর নিজের user-data area-তেই বসানো থাকে — মেমরির একই জায়গা দুই
ভূমিকায় পুনর্ব্যবহৃত হয়)। একই chunk দুইবার ঢুকিয়ে দিলে সেই linked
structure-এ একটা cycle বা duplicate entry তৈরি হয় — যার সরাসরি
ফলাফল: পরবর্তী দুইটা malloc() কল একই ঠিকানা ফেরত দিতে পারে, দুইটা
সম্পূর্ণ ভিন্ন, “স্বাধীন” object-এর জন্য। এই লেসনের hood আর experiment
অংশে এই মেকানিজম দুটোই — UAF আর double-free — একটা বাস্তব চালানো toy
allocator দিয়ে ধাপে ধাপে দেখানো হবে।
| Use-after-free | Double-free | |
|---|---|---|
| কী corrupt হয় | pointer-এর পেছনের ডেটা | allocator-এর metadata (free-list linkage) |
| ন্যূনতম bug | একটা stale read/write | একটা দ্বিতীয়, অপ্রয়োজনীয় free() কল |
| সাধারণ outcome | info-leak, type confusion | heap corruption, aliasing, arbitrary allocation |
| CWE | CWE-416 | CWE-415 |
ভেতরে কী ঘটছে
একটা toy allocator-এ UAF আসলে চালিয়ে দেখা
hand-wave নয় — নিচের toy allocator ঠিক Memory Allocation লেসনের ভাষায়
তৈরি: একটা dict-backed heap (ঠিকানা → chunk), আর একটা free list
যেটা chunk-এর নিজের fd field (glibc-র fastbin-এর মতোই — free chunk-এর
user-data area-ই free-list-এর “next” পয়েন্টার হিসেবে পুনর্ব্যবহৃত হয়)
দিয়ে গঠিত।
# uaf.py -- toy allocator: dict-backed heap + free-list, showing a real
# use-after-free (stale reference reads attacker-controlled reused memory)
class Chunk:
__slots__ = ("addr", "size", "in_use", "data", "fd")
def __init__(self, addr, size):
self.addr = addr
self.size = size
self.in_use = False
self.data = None
self.fd = None # forward-pointer, valid only while chunk is free
class ToyAllocator:
"""Dict-backed heap + a singly-linked free list, same shape as glibc's
fastbin: a free chunk's own user-data area doubles as the 'fd' pointer
to the next free chunk. Reusing that memory for two purposes is exactly
the seam use-after-free and double-free exploit."""
def __init__(self):
self.heap = {}
self.free_head = None
self._next_addr = 0x1000
def alloc(self, size, value):
if self.free_head is not None:
addr = self.free_head
chunk = self.heap[addr]
self.free_head = chunk.fd # unlink head, follow fd
chunk.fd = None
else:
addr = self._next_addr
self._next_addr += size
self.heap[addr] = Chunk(addr, size)
chunk = self.heap[addr]
chunk.in_use = True
chunk.data = value
return addr
def free(self, addr):
chunk = self.heap[addr]
chunk.in_use = False
chunk.fd = self.free_head # push onto free list (LIFO)
self.free_head = addr
# NOTE: data is deliberately NOT wiped -- real allocators don't
# zero freed memory by default either
def read(self, addr):
return self.heap[addr].data
alloc = ToyAllocator()
print("== ব্যবহারকারীর কোড: session token allocate ==")
a = alloc.alloc(32, "session-token:alice:role=user")
print(f" A = alloc(32, safe-value) -> addr={hex(a)}")
stale_ref = a # legit code keeps this pointer around after freeing (the bug)
print(f" read(A) = {alloc.read(a)!r}")
print("\n== bug: free(A) হলো, কিন্তু stale_ref এখনো A-কেই দেখাচ্ছে ==")
alloc.free(a)
print(f" free(A) called -- heap[A].in_use = {alloc.heap[a].in_use}")
print(f" stale_ref এখনো == A? {stale_ref == a}")
print("\n== attacker: একই size চেয়ে নতুন allocation করছে ==")
b = alloc.alloc(32, "ATTACKER-CONTROLLED-PAYLOAD:admin=true")
print(f" B = alloc(32, attacker-value) -> addr={hex(b)}")
print(f" B এবং A একই address? {b == a} (free-list-এ একটাই free chunk ছিল, তাই reuse বাধ্যতামূলক)")
print("\n== victim: stale_ref দিয়ে আবার read (UAF!) ==")
leaked = alloc.read(stale_ref)
print(f" read(stale_ref) = {leaked!r}")
print(f" stale_ref আসলে B-এর ডেটা ফেরত দিলো, A-র নয় : {leaked == alloc.read(b)}")প্রকৃত চালিয়ে পাওয়া output (deterministic — হুবহু এটাই আসবে):
== ব্যবহারকারীর কোড: session token allocate ==
A = alloc(32, safe-value) -> addr=0x1000
read(A) = 'session-token:alice:role=user'
== bug: free(A) হলো, কিন্তু stale_ref এখনো A-কেই দেখাচ্ছে ==
free(A) called -- heap[A].in_use = False
stale_ref এখনো == A? True
== attacker: একই size চেয়ে নতুন allocation করছে ==
B = alloc(32, attacker-value) -> addr=0x1000
B এবং A একই address? True (free-list-এ একটাই free chunk ছিল, তাই reuse বাধ্যতামূলক)
== victim: stale_ref দিয়ে আবার read (UAF!) ==
read(stale_ref) = 'ATTACKER-CONTROLLED-PAYLOAD:admin=true'
stale_ref আসলে B-এর ডেটা ফেরত দিলো, A-র নয় : Trueমূল মুহূর্তটা শেষ লাইনে: stale_ref variable-টা কখনো বদলায়নি — এখনও
0x1000-কেই দেখাচ্ছে, ঠিক প্রথম দিনের মতো। কিন্তু সেই ঠিকানায় এখন যা আছে
তা সম্পূর্ণ ভিন্ন, attacker-নির্ধারিত ডেটা। প্রোগ্রামের দৃষ্টিকোণ থেকে
কিছুই “ভুল” মনে হয় না — read() একটা string-ই ফেরত দিয়েছে, কোনো crash
নেই। বাস্তব C++ প্রোগ্রামে এই একই প্যাটার্ন ঘটে যখন একটা object free হয়ে
যায় কিন্তু কোনো callback, event-listener, বা cache-এ তার pointer-এর একটা
কপি থেকে যায় — attacker যদি সেই ঠিকানায় নিজের নিয়ন্ত্রিত object বসাতে
পারে (heap grooming/feng shui নামের একটা পরিচিত কৌশলে), stale pointer-টা
তখন সরাসরি attacker-এর ডেটার ওপর কাজ করতে শুরু করে।
- application logicএকটা object free হয়ে গেছে, কিন্তু কোনো callback/cache/iterator-এ তার pointer-এর কপি রয়ে গেছে
- language/compilerC/C++-এ pointer lifetime যাচাই করার কোনো বাধ্যতামূলক mechanism নেই — programmer-এর দায়িত্ব
- allocator (chunk/free-list)free() শুধু bookkeeping বদলায়; ডেটা মোছে না, pointer-কে জানায়ও না
- attacker-controlled reallocationএকই size-class-এর একটা নতুন allocation একই ঠিকানা পুনর্ব্যবহার করে, attacker-নির্ধারিত ডেটাসহ
উদাহরণ
একটা concrete timeline — কেন এই বাগ “মাঝে মাঝে কাজ করে না” রকমের বিরক্তিকর
Use-after-free-এর একটা কুখ্যাত বৈশিষ্ট্য: exploitability সম্পূর্ণভাবে নির্ভর করে টাইমিং-এর ওপর — free আর reuse-এর মাঝখানে ঠিক কী allocate হচ্ছে তার ওপর। নিচের timeline-টা একটা বাস্তব browser-স্টাইল দৃশ্য (একটা DOM node-এর callback থেকে ধরে):
| সময় | ঘটনা | heap-এর অবস্থা |
|---|---|---|
| t0 | Node* n = new Node(...) | chunk allocate, n বৈধ |
| t1 | কোনো event listener n-এর pointer একটা callback queue-তে কপি করে রাখল | দ্বিতীয় (দুর্বল না, কিন্তু “লুকানো”) reference তৈরি হলো |
| t2 | delete n; মূল owner object destroy করল | chunk free-list-এ ফিরে গেল — কিন্তু callback queue-র কপি এখনো আছে |
| t3 | attacker একটা নতুন, একই-size-এর JS object তৈরি করাল (heap grooming) | নতুন object পুরনো n-এর ঠিকানাই পেয়ে গেল |
| t4 | event loop callback queue-র সেই পুরনো n-কে ডাকল | dangling pointer dereference — কিন্তু এখন সেটা attacker-এর object-কে Node* হিসেবে treat করছে |
t2 আর t4-এর মাঝখানে যদি attacker t3-এর “heap grooming” করতে না পারে (অন্য কোনো allocation সেই স্লট আগেই নিয়ে নেয়, বা কোনোটাই নয়), t4-এ crash হয় বা কিছুই লক্ষণীয় ঘটে না — bug report-এ “intermittent,” “couldn’t reproduce” লেখা হয়। এই একই বাগ, শুধু টাইমিং একটু বদলে, exploitable হয়ে ওঠে। এই অনিশ্চয়তাই UAF-কে debug করা এত কঠিন করে তোলে — আর ঠিক এই কারণেই attacker-রা heap grooming-কে একটা independent দক্ষতা হিসেবে রপ্ত করে, শুধু বাগ খুঁজে বের করা নয়।
ছবিতে
স্বাভাবিক: free(A) তারপর free(B)
head ──► A(fd=None) (free(A)-এর পরে)
head ──► B(fd=A) ──► A(fd=None) (free(B)-এর পরে, স্বাভাবিক LIFO chain)
DOUBLE-FREE: free(A) আবার (কোনো check ছাড়াই)
head ──► A(fd=B) ──► B(fd=A) ──► A(fd=B) ──► ... (CYCLE!)
পরের ৩টা alloc():
alloc#1 -> pop A (A বরাদ্দ হলো)
alloc#2 -> pop B (B বরাদ্দ হলো)
alloc#3 -> pop A (আবার!) (A আবার বরাদ্দ — কিন্তু A ইতিমধ্যে alloc#1-এর কাছে!)
ফলাফল: alloc#1 আর alloc#3 — দুইটা "আলাদা" object আসলে
একই মেমরিকে alias করছে। allocator নিজে জানেই না।double-free-এর পরের cycle: head ──► A(fd=B) ──► B(fd=A) ──► ...
attacker-এর stale-write (এখনো-জীবিত পুরনো pointer দিয়ে B-এর fd ওভাররাইট):
B.fd = 0xdeadbeef ← attacker-এর বেছে নেওয়া যেকোনো "ঠিকানা"
free-list এখন: head ──► A(fd=B) ──► B(fd=0xdeadbeef) ──► 0xdeadbeef ──► ???
alloc#1 -> pop A
alloc#2 -> pop B
alloc#3 -> pop 0xdeadbeef ← malloc() attacker-এর ঠিকানাই ফেরত দিলো!
caller এখন 0xdeadbeef-কে "নিজের" মেমরি মনে করে সেখানে লিখবে —
সেই ঠিকানায় যদি সত্যিই কিছু গুরুত্বপূর্ণ থাকে (function pointer,
vtable, অন্য object-এর ভেতর), এটাই write-what-where primitive।এই দুইটা ছবিই এক জিনিসের দুই ধাপ — প্রথমটা দেখায় double-free কীভাবে allocator-কে বিভ্রান্ত করে (aliasing), দ্বিতীয়টা দেখায় সেই বিভ্রান্তি কীভাবে নিয়ন্ত্রিত করা যায় (attacker-নির্ধারিত ঠিকানা ফেরত পাওয়া)। পরের অংশে দুটোই আসলে চালিয়ে দেখানো হবে।
নিজে চালিয়ে দেখুন
Double-free থেকে write-what-where — সংখ্যায়, ধাপে ধাপে
# double_free.py -- same ToyAllocator, showing double-free corrupting the
# free-list into a cycle (aliasing), then a UAF write turning that cycle
# into a write-what-where: malloc() handing back an attacker-chosen address.
class Chunk:
__slots__ = ("addr", "size", "in_use", "data", "fd")
def __init__(self, addr, size):
self.addr = addr
self.size = size
self.in_use = False
self.data = None
self.fd = None
class ToyAllocator:
def __init__(self):
self.heap = {}
self.free_head = None
self._next_addr = 0x1000
def alloc(self, size, value):
if self.free_head is not None:
addr = self.free_head
chunk = self.heap.get(addr)
if chunk is None:
# allocator TRUSTS the free-list link blindly -- it never
# validates that `addr` was ever a real chunk it handed out.
# A real allocator does the equivalent: it reads whatever
# bytes sit at that address as if they were a chunk header.
chunk = Chunk(addr, size)
self.heap[addr] = chunk
self.free_head = chunk.fd
chunk.fd = None
else:
addr = self._next_addr
self._next_addr += size
self.heap[addr] = Chunk(addr, size)
chunk = self.heap[addr]
chunk.in_use = True
chunk.data = value
return addr
def free(self, addr):
# NO double-free check -- exactly like a raw fastbin free()
chunk = self.heap[addr]
chunk.fd = self.free_head
self.free_head = addr
chunk.in_use = False
def read(self, addr):
return self.heap[addr].data
def write_fd(self, addr, fake_fd):
# simulates a UAF *write* through a stale pointer: attacker
# overwrites the fd field living in the freed chunk's own
# user-data area
self.heap[addr].fd = fake_fd
def free_list_chain(self, limit=6):
out, cur, seen = [], self.free_head, set()
while cur is not None and len(out) < limit:
out.append(hex(cur))
if cur in seen:
out.append("...(cycle)")
break
seen.add(cur)
cur = self.heap[cur].fd
return " -> ".join(out) if out else "(empty)"
alloc = ToyAllocator()
print("== ধাপ ১: A আর B allocate করা হলো ==")
a = alloc.alloc(16, "chunk-A")
b = alloc.alloc(16, "chunk-B")
print(f" A={hex(a)} B={hex(b)}")
print("\n== ধাপ ২: free(A), free(B) -- স্বাভাবিক free ==")
alloc.free(a)
alloc.free(b)
print(f" free-list এখন: {alloc.free_list_chain()}")
print("\n== ধাপ ৩: free(A) আবার -- DOUBLE FREE, কোনো check নেই ==")
alloc.free(a)
print(f" free-list এখন: {alloc.free_list_chain()}")
print(" -> A নিজের fd দিয়ে B-কে দেখাচ্ছে, B-র fd আবার A-কে দেখাচ্ছে -- একটা CYCLE")
print("\n== ধাপ ৪: এখন তিনটা alloc() ডাকা হলো ==")
c1 = alloc.alloc(16, "attacker-obj-1")
c2 = alloc.alloc(16, "attacker-obj-2")
c3 = alloc.alloc(16, "attacker-obj-3")
print(f" alloc#1 -> {hex(c1)} (data={alloc.read(c1)!r})")
print(f" alloc#2 -> {hex(c2)} (data={alloc.read(c2)!r})")
print(f" alloc#3 -> {hex(c3)} (data={alloc.read(c3)!r})")
print(f" alloc#1 == alloc#3 (একই address দুইবার ফেরত এসেছে)? {c1 == c3}")
print(f" alloc#1-এর data এখন আসলে alloc#3-এর লেখা মান : {alloc.read(c1) == alloc.read(c3)}")
print(" -> দুইটা 'independent' object আসলে একই মেমরিকে alias করছে --")
print(" একটাতে লিখলে অন্যটাও বদলে যায়, allocator নিজে জানেই না।")
print("\n== ধাপ ৫: write-what-where -- UAF-write দিয়ে fd corrupt করা ==")
alloc2 = ToyAllocator()
a = alloc2.alloc(16, "chunk-A")
b = alloc2.alloc(16, "chunk-B")
alloc2.free(a)
alloc2.free(b)
alloc2.free(a) # double-free -> cycle, যেমন আগে
print(f" double-free-এর পরের free-list: {alloc2.free_list_chain()}")
FAKE_TARGET = 0xdeadbeef # attacker-chosen arbitrary "address"
alloc2.write_fd(b, FAKE_TARGET) # stale pointer দিয়ে B-র fd সরাসরি ওভাররাইট
print(f" attacker stale-write করল heap[B].fd = {hex(FAKE_TARGET)}")
print(f" free-list এখন (আংশিক, দেখানোর জন্য ৩টা hop): "
f"{hex(alloc2.free_head)} -> {hex(alloc2.heap[alloc2.free_head].fd)} -> "
f"{hex(alloc2.heap[alloc2.heap[alloc2.free_head].fd].fd)}")
d1 = alloc2.alloc(16, "victim-obj") # pops A
d2 = alloc2.alloc(16, "victim-obj-2") # pops B, exposes FAKE_TARGET as new head
d3 = alloc2.alloc(16, "attacker-write-here") # pops FAKE_TARGET !!
print(f" alloc#1 -> {hex(d1)}")
print(f" alloc#2 -> {hex(d2)}")
print(f" alloc#3 -> {hex(d3)}")
print(f" alloc#3 attacker-এর বেছে-নেওয়া FAKE_TARGET-এর সমান? {d3 == FAKE_TARGET}")
print(" -> allocator attacker-নির্ধারিত একটা arbitrary address ফেরত দিল --")
print(" caller এখন সেখানে 'নিজের' object মনে করে লিখবে: write-what-where.")প্রকৃত চালিয়ে পাওয়া output (deterministic — হুবহু এটাই আসবে):
== ধাপ ১: A আর B allocate করা হলো ==
A=0x1000 B=0x1010
== ধাপ ২: free(A), free(B) -- স্বাভাবিক free ==
free-list এখন: 0x1010 -> 0x1000
== ধাপ ৩: free(A) আবার -- DOUBLE FREE, কোনো check নেই ==
free-list এখন: 0x1000 -> 0x1010 -> 0x1000 -> ...(cycle)
-> A নিজের fd দিয়ে B-কে দেখাচ্ছে, B-র fd আবার A-কে দেখাচ্ছে -- একটা CYCLE
== ধাপ ৪: এখন তিনটা alloc() ডাকা হলো ==
alloc#1 -> 0x1000 (data='attacker-obj-3')
alloc#2 -> 0x1010 (data='attacker-obj-2')
alloc#3 -> 0x1000 (data='attacker-obj-3')
alloc#1 == alloc#3 (একই address দুইবার ফেরত এসেছে)? True
alloc#1-এর data এখন আসলে alloc#3-এর লেখা মান : True
-> দুইটা 'independent' object আসলে একই মেমরিকে alias করছে --
একটাতে লিখলে অন্যটাও বদলে যায়, allocator নিজে জানেই না।
== ধাপ ৫: write-what-where -- UAF-write দিয়ে fd corrupt করা ==
double-free-এর পরের free-list: 0x1000 -> 0x1010 -> 0x1000 -> ...(cycle)
attacker stale-write করল heap[B].fd = 0xdeadbeef
free-list এখন (আংশিক, দেখানোর জন্য ৩টা hop): 0x1000 -> 0x1010 -> 0xdeadbeef
alloc#1 -> 0x1000
alloc#2 -> 0x1010
alloc#3 -> 0xdeadbeef
alloc#3 attacker-এর বেছে-নেওয়া FAKE_TARGET-এর সমান? True
-> allocator attacker-নির্ধারিত একটা arbitrary address ফেরত দিল --
caller এখন সেখানে 'নিজের' object মনে করে লিখবে: write-what-where.ধাপ ৪-এর alloc#1-এর data এখন আসলে alloc#3-এর লেখা মান : True লাইনটা
সবচেয়ে গুরুত্বপূর্ণ — এটা দেখায় দুইটা সম্পূর্ণ ভিন্ন “handle” (c1, c3)
আসলে একই মেমরির ওপর কাজ করছে, allocator নিজেই এই দ্বৈত-মালিকানা সম্পর্কে
কিছু জানে না। আর ধাপ ৫ দেখায় এই একই দুর্বলতা কতদূর বাড়ানো যায় — বাস্তব
CTF আর real-world exploit-এ এই কৌশলটার নাম fastbin dup (double-free
করে একটা fastbin-এ duplicate entry তৈরি করা), আর এর সম্প্রসারণ (fake
chunk প্লান্ট করে arbitrary address মেমরি বরাদ্দ পাওয়া) পরিচিত House of
Spirit-জাতীয় কৌশল হিসেবে। glibc-র আধুনিক সংস্করণে tcache/fastbin-এ
এখন সাধারণ ক্ষেত্রে একটা সরল double-free check আছে (সরাসরি পরপর একই
chunk free করলে ধরা পড়ে) — কিন্তু মাঝখানে একটা ভিন্ন chunk free করে সেই
check bypass করা সম্ভব, যা দেখায় mitigation প্রায়ই “সহজ কেস” ধরে, প্যাটার্ন
সম্পূর্ণ বন্ধ করে না।
একই chunk দুইবার free() করলে free-list-এর singly-linked গঠনে একটা cycle তৈরি হয়, যার ফলে দুইটা 'স্বাধীন' allocation আসলে একই ঠিকানাকে alias করে (aliasing) — আর একটা মাত্র অতিরিক্ত UAF-write দিয়ে সেই cycle-কে malloc()-কে attacker-নির্ধারিত arbitrary ঠিকানা ফেরত দিতে বাধ্য করা যায় (write-what-where)।
নিজে বানান
GenerationGuard — free()-কে 'ভুলে-যাওয়া' থেকে 'মনে-রাখা' বানানো
- প্রতিটা chunk-এ একটা generation counter যোগ করুন — শুরুতে 0
- alloc()-এর ফেরত-দেওয়া handle-এ (addr, generation) দুটোই বহন করান, শুধু addr না
- free()-এ generation +১ বাড়ান — chunk পুনর্ব্যবহারযোগ্য থাকুক, কিন্তু পুরনো handle-এর generation এখন stale হয়ে যাক
- read()/free() দুটোতেই handle.generation বনাম chunk.generation মিলিয়ে দেখুন — না মিললে exception, silent bug নয়
- দেখান একই bug (UAF read, double-free) — দুটোই এখন সশব্দ, তাৎক্ষণিক exception হয়ে ধরা পড়ছে
লক্ষ্য: আগের দুই experiment-এর একই bug-দৃশ্য নিয়ে, allocator-এ একটা generation-tag mitigation বসিয়ে দেখানো — এটাই বাস্তব hardened allocator-এর (Chrome-এর MiraclePtr/BackupRefPtr-জাতীয়) মূল ধারণার একটা সরলীকৃত সংস্করণ: pointer নিজে raw address না রেখে, address + একটা “generation/epoch” বহন করে, আর allocator dereference-এর সময় সেই generation মিলিয়ে দেখে।
# generation_guard.py -- a generation-tagged allocator that turns silent
# UAF/double-free into a loud, immediate exception (MiraclePtr/BackupRefPtr
# style mitigation, simplified).
class UseAfterFreeError(Exception):
pass
class Handle:
__slots__ = ("addr", "generation")
def __init__(self, addr, generation):
self.addr = addr
self.generation = generation
def __repr__(self):
return f"Handle(addr={hex(self.addr)}, gen={self.generation})"
class SafeChunk:
__slots__ = ("addr", "size", "in_use", "data", "generation")
def __init__(self, addr, size):
self.addr = addr
self.size = size
self.in_use = False
self.data = None
self.generation = 0
class GenerationGuardedAllocator:
"""প্রতিটা chunk-এ একটা generation counter -- free()-এ সেটা বাড়ে। Handle
নিজের generation বহন করে; dereference-এর সময় মিলিয়ে দেখা হয়। মিল না
থাকলে exception -- silent UAF/double-free-এর বদলে সশব্দ crash।"""
def __init__(self):
self.heap = {}
self.free_list = []
self._next_addr = 0x2000
def alloc(self, size, value):
if self.free_list:
addr = self.free_list.pop()
chunk = self.heap[addr]
else:
addr = self._next_addr
self._next_addr += size
chunk = SafeChunk(addr, size)
self.heap[addr] = chunk
chunk.in_use = True
chunk.data = value
return Handle(addr, chunk.generation)
def free(self, handle):
chunk = self.heap[handle.addr]
if not chunk.in_use or chunk.generation != handle.generation:
raise UseAfterFreeError(
f"double-free অথবা stale handle দিয়ে free() ব্লক করা হলো "
f"(addr={hex(handle.addr)}, handle-gen={handle.generation}, "
f"chunk-gen={chunk.generation})"
)
chunk.in_use = False
chunk.generation += 1 # <- মূল রক্ষাকবচ: এই এক লাইন
self.free_list.append(handle.addr)
def read(self, handle):
chunk = self.heap[handle.addr]
if chunk.generation != handle.generation:
raise UseAfterFreeError(
f"UAF ব্লক করা হলো: handle-gen={handle.generation}, "
f"বর্তমান chunk-gen={chunk.generation} (addr={hex(handle.addr)})"
)
return chunk.data
guard = GenerationGuardedAllocator()
print("== ধাপ ১: A allocate, তারপর free -- generation 0 -> 1 ==")
a = guard.alloc(32, "safe-session-token")
stale = a
print(f" A = {a!r}")
guard.free(a)
print(f" free(A) সম্পন্ন -- heap[A].generation এখন {guard.heap[a.addr].generation}")
print("\n== ধাপ ২: B একই slot পায়, নতুন generation নিয়ে ==")
b = guard.alloc(32, "attacker-controlled-payload")
print(f" B = {b!r} (B.addr == A.addr : {b.addr == a.addr})")
print("\n== ধাপ ৩: stale handle দিয়ে read -- এবার আর silent leak না ==")
try:
guard.read(stale)
print(" UAF সফল হলো -- এটা হওয়ার কথা না!")
except UseAfterFreeError as e:
print(f" BLOCKED: {e}")
print("\n== ধাপ ৪: একই stale handle দিয়ে আবার free() -- double-free-ও ব্লক ==")
try:
guard.free(stale)
print(" double-free সফল হলো -- এটা হওয়ার কথা না!")
except UseAfterFreeError as e:
print(f" BLOCKED: {e}")
print("\n== ধাপ ৫: legit owner (B) স্বাভাবিকভাবেই কাজ করে যাচ্ছে ==")
print(f" read(B) = {guard.read(b)!r}")প্রকৃত চালিয়ে পাওয়া output (deterministic — হুবহু এটাই আসবে):
== ধাপ ১: A allocate, তারপর free -- generation 0 -> 1 ==
A = Handle(addr=0x2000, gen=0)
free(A) সম্পন্ন -- heap[A].generation এখন 1
== ধাপ ২: B একই slot পায়, নতুন generation নিয়ে ==
B = Handle(addr=0x2000, gen=1) (B.addr == A.addr : True)
== ধাপ ৩: stale handle দিয়ে read -- এবার আর silent leak না ==
BLOCKED: UAF ব্লক করা হলো: handle-gen=0, বর্তমান chunk-gen=1 (addr=0x2000)
== ধাপ ৪: একই stale handle দিয়ে আবার free() -- double-free-ও ব্লক ==
BLOCKED: double-free অথবা stale handle দিয়ে free() ব্লক করা হলো (addr=0x2000, handle-gen=0, chunk-gen=1)
== ধাপ ৫: legit owner (B) স্বাভাবিকভাবেই কাজ করে যাচ্ছে ==
read(B) = 'attacker-controlled-payload'একটা মাত্র লাইন — chunk.generation += 1 — দুটো ভিন্ন বাগকে একসাথে
ব্লক করে দিলো, কারণ দুটোরই মূল কারণ একই: stale-কে fresh থেকে আলাদা
করার কোনো উপায় ছিল না। generation tag ঠিক সেই আলাদা-করার তথ্যটুকুই
যোগ করেছে। এটাই লক্ষ্য করার মতো — mitigation বাগ “ঠিক করেনি” (এখনও
programmer stale handle ধরে রেখেছে), শুধু সেই বাগকে silent থেকে
loud বানিয়ে দিয়েছে — production-এ এটাই একটা crash/log হয়ে ধরা পড়ে,
security incident হয়ে নয়।
নিজে বাড়ান:
free_list-কে LIFO stack না রেখে, address-অনুযায়ী sorted রাখলে কি aliasing-ধাঁচের bug কোনোভাবে সহজ হয় বা কঠিন হয়? যুক্তি দিনread()-এর মতো একটাwrite(handle, value)মেথড যোগ করুন — একই generation-check প্রযোজ্য কিনা যাচাই করুন- এই generation-tag পদ্ধতির খরচ কী? (ইঙ্গিত: প্রতিটা pointer এখন দুইটা field বহন করছে, প্রতিটা dereference-এ একটা তুলনা যোগ হচ্ছে — বাস্তব MiraclePtr-এ Chrome টিম এই overhead পরিমাপ করে প্রকাশ করেছিল, সিঙ্গল-ডিজিট শতাংশ পারফরম্যান্স খরচে)
বাস্তব সিস্টেমে
বাস্তব জগতে — কেন এই দুইটা বাগ আজকের রাজা
Chromium — memory-safety bug-ই majority। Chrome security টিমের নিজস্ব দীর্ঘমেয়াদি পরিসংখ্যান বলছে — বছরের পর বছর ধরে Chrome-এর high/critical-severity security bug-এর প্রায় ৭০% memory-safety issue, আর সেই ভাগের একটা বড় অংশ use-after-free (Chromium-এর নিজস্ব bug tracker-এ শত শত UAF CVE খুঁজে পাওয়া যায়, প্রায়ই DOM node lifecycle, event listener, বা renderer/browser process-এর মধ্যে object ownership জটিলতা থেকে)। এই একটা সংখ্যাই পুরো Chrome security টিমের একটা বড় কৌশলগত সিদ্ধান্তের ভিত্তি — শুধু বাগ খুঁজে ঠিক করা না, বরং পুরো category-টাকেই structurally কমিয়ে দেওয়া।
MiraclePtr / BackupRefPtr — আজকের build অংশের বাস্তব সংস্করণ। Chrome
২০২১-এর দিকে renderer process-এ raw_ptr<T> নামের একটা smart-pointer
টাইপ চালু করে, যা ঠিক আজকের GenerationGuardedAllocator-এর মতোই একটা
ধারণায় কাজ করে — allocation-এর metadata-তে একটা reference-count/quarantine
flag রাখা হয়, free() কল হলে সেই memory তাৎক্ষণিক OS-কে ফেরত না দিয়ে
“quarantine”-এ রাখা হয় যতক্ষণ না সব known raw_ptr reference শেষ হয়ে
যায় — dangling access হলে সেটা crash করায় (নিরাপদভাবে, exploitable
নয়), আসল ডেটা ফেরত দেয় না। Chrome-এর প্রকাশিত তথ্য অনুযায়ী এটা renderer
process-এর UAF-ভিত্তিক exploitability-কে উল্লেখযোগ্যভাবে কমিয়ে দিয়েছে,
সামান্য (single-digit percentage) performance overhead-এর বিনিময়ে —
ঠিক আজকের build-অংশের “নিজে বাড়ান” প্রশ্নের উত্তর, বাস্তব স্কেলে।
Linux kernel — UAF দীর্ঘদিনের privilege-escalation উৎস। Linux
kernel-এর অসংখ্য উচ্চ-severity CVE (নেটওয়ার্ক subsystem, filesystem
driver, io_uring-এর মতো নতুন subsystem-এ বিশেষভাবে ঘন ঘন) মূলত
use-after-free — এখানে “attacker-controlled data” মানে অনেক সময় সরাসরি
kernel-এর নিজস্ব object graph-এর একটা অংশ, আর সফল exploitation প্রায়ই
সরাসরি local privilege escalation-এ (root shell) রূপ নেয়। এই কারণেই
kernel টিম নিজস্ব hardened allocator features (যেমন CONFIG_SLAB_FREELIST_HARDENED,
যা free-list pointer-কে একটা random cookie দিয়ে XOR করে রাখে — অনেকটা
আজকের fastbin dup আক্রমণকে কঠিন করার একটা সরাসরি countermeasure) চালু
রেখেছে ডিফল্টভাবে অনেক distribution-এ।
MSRC (Microsoft) — এক দশকের বেশি সময়ের প্যাটার্ন। Microsoft Security Response Center-এর প্রকাশিত বিশ্লেষণে (Matt Miller, BlueHat IL 2019) প্রায় ১২ বছরের CVE ডেটা দেখিয়েছে — প্রতিটা বছরের Microsoft-assigned CVE-র প্রায় ৭০% ধারাবাহিকভাবে memory-safety issue। এই একই সংখ্যা, দুই সম্পূর্ণ ভিন্ন organization-এর (Google-এর Chrome, Microsoft-এর Windows) codebase-এ, একই কারণেই আজ বড় বড় প্রতিষ্ঠান (Microsoft, Google, Mozilla, Android) নতুন system-level কোডে Rust-এর দিকে সরে যাচ্ছে — নিচের misconception অংশে এই “কেন Rust” প্রশ্নটা আরও স্পষ্ট করা হবে।
যে ভুলগুলো সবাই করে
“free() করার সাথে সাথেই সেই মেমরির ডেটা মুছে যায় — তাই dangling pointer পড়লে গার্বেজ পাওয়া যাবে, সংবেদনশীল কিছু না।”
Experiment-টাই সরাসরি এর বিরুদ্ধে প্রমাণ — free() ডেটা মোছে না, শুধু
allocator-এর bookkeeping বদলায় (Memory Allocation লেসনের ভাষায়: chunk-টা
free-list-এ ফিরে যায় মাত্র)। যতক্ষণ না সেই ঠিকানা কোনো নতুন malloc()-এর
হাতে পড়ে, পুরনো ডেটা হুবহু সেখানেই থাকে — password, session token,
cryptographic key, যা-ই থাকুক না কেন। এই ভুল ধারণার বাস্তব বিপদ:
sensitive data-র জন্য শুধু free() যথেষ্ট মনে করে না-মুছে রাখা — সেই
কারণেই secure coding guideline-এ free()-এর আগে sensitive buffer
memset() দিয়ে zero-out করার সুপারিশ থাকে (compiler optimize-away না
করে দেয় সেটাও একটা আলাদা, পরিচিত সমস্যা)।
“দ্বিতীয়বার free(p) কল করার পর অন্তত p-কে আর dereference না করলে কোনো ক্ষতি নেই — এটা শুধু একটা 'নিরীহ' redundant কল।”
Experiment-এর ধাপ ৩-৫ সরাসরি এর বিরুদ্ধে — দ্বিতীয় free() কলটাই
আসল ক্ষতি করে, তার পরে p-কে dereference করা লাগেই না। শুধু
free(p); free(p); — দুইটা কলের মাঝে p স্পর্শও না করেও — free-list-এর
metadata-কে corrupt করার জন্য যথেষ্ট, যা পরবর্তী সম্পূর্ণ অসম্পর্কিত
malloc() কলগুলোর আচরণ বদলে দেয়। Double-free-এর বিপদ dereference-এ না,
বরং allocator-এর internal state-এ।
“Use-after-free শুধু C/C++-এর সমস্যা — garbage-collected ভাষায় (Java, Python, JS) এটা ঘটতেই পারে না।”
Garbage collector থাকলে সরাসরি manual free()-জনিত UAF ঘটে না —
এটা সত্যি। কিন্তু ভাষা-স্তরের garbage collection এই পুরো সমস্যা-শ্রেণিটা
মুছে দেয় না, শুধু স্তর পাল্টে দেয়। C++-এ ইমপ্লিমেন্ট করা runtime/engine
(V8, JVM-এর নিজস্ব native অংশ, Python-এর CPython interpreter নিজে)
এখনও C++-এই লেখা, আর সেই native স্তরে ঠিক এই একই বাগ ঘটে — Chrome-এর
বেশিরভাগ UAF আসলে V8 বা Blink-এর (renderer engine) নিজস্ব C++ কোডে,
JavaScript প্রোগ্রামারের লেখা কোডে না। আর কিছু ভাষায় (Python-এর
ctypes, unsafe reflection) programmer ইচ্ছাকৃতভাবে GC-এর নিরাপত্তা
বাইপাস করেও এই বাগ আনতে পারে।
“একটা pointer-কে free()-এর পরে NULL বসিয়ে দিলেই (defensive nulling) use-after-free পুরোপুরি সমাধান হয়ে যায়।”
Realworld সেকশনের mitigation-টেবিলে এই পার্থক্যটাই মূল বিষয় — nulling
শুধু সেই একটা variable-কে রক্ষা করে। বাস্তব প্রোগ্রামে একই object-এর
pointer প্রায়ই একাধিক জায়গায় কপি হয়ে থাকে — callback queue, cache,
অন্য একটা struct-এর member, iterator। p = NULL শুধু p-কেই বদলায়,
বাকি সব কপি (aliasing pointer) তখনও dangling। এই কারণেই hardened
allocator-এর মতো broader mitigation দরকার — অথবা, একেবারে মূলে, Rust-এর
ownership model-এর মতো কিছু যেখানে “কে owner, কে borrower” কম্পাইল-টাইমেই
ট্র্যাক করা হয়, শুধু programmer-এর মনে রাখার ওপর নির্ভর না করে।
বুঝেছেন কি না দেখুন
1Use-after-free আর double-free-এর সংজ্ঞায় মূল পার্থক্য কী — একটাতে ঠিক
কী corrupt হয় (ডেটা নাকি metadata), অন্যটাতে কী?
স্মরণ
Use-after-free: free() করা মেমরির ডেটা অংশ একটা stale pointer দিয়ে
পড়া/লেখা হয় — attacker-নির্ধারিত ডেটা বা type confusion সরাসরি ঘটে।
Double-free: একই pointer-এ দুইবার free() ডাকা হয়, যা allocator-এর
free-list metadata-কে corrupt করে (cycle/duplicate entry তৈরি করে),
dereference ছাড়াই।
2Concept সেকশনের টেবিলে দেখা গেছে — সবচেয়ে বিপজ্জনক UAF outcome (attacker
নিয়ন্ত্রিত ডেটা) আসলে সবচেয়ে “শান্ত” (কোনো crash নেই)। কেন এই বিপরীতমুখী
সম্পর্কটাই UAF-কে buffer-overflow-এর চেয়েও প্রায়ই বেশি বিপজ্জনক করে তোলে?
যুক্তি
কারণ crash একটা সংকেত — মনিটরিং, crash-reporting, QA প্রক্রিয়ায় ধরা
পড়ে, দ্রুত ফিক্স হয়। যে UAF কোনো crash ঘটায় না (attacker-এর বসানো ডেটা
programঠিকই “বৈধ-দেখতে” ফলাফল ফেরত দেয়), সেটা প্রোডাকশনে বছরের পর বছর
নিঃশব্দে টিকে থাকতে পারে, কোনো error log ছাড়াই — ঠিক যেমন experiment-এ
read(stale_ref) কোনো exception না তুলে সরাসরি attacker-এর payload
ফেরত দিয়েছে। Silent corruption সংকেতহীন, তাই detect করা অনেক কঠিন।
3Experiment-এর ধাপ ৪-এ alloc#1 == alloc#3 (True) পাওয়া গেছে, অথচ
মাঝখানে alloc#2 একটা ভিন্ন ঠিকানা (B) পেয়েছিল। free-list-এর কোন
নির্দিষ্ট গঠনগত কারণে ঠিক এই ক্রমে (A, B, A) ঠিকানা তিনটা ফেরত এলো?
প্রয়োগ
alloc#1 == alloc#3 (True) পাওয়া গেছে, অথচ
মাঝখানে alloc#2 একটা ভিন্ন ঠিকানা (B) পেয়েছিল। free-list-এর কোন
নির্দিষ্ট গঠনগত কারণে ঠিক এই ক্রমে (A, B, A) ঠিকানা তিনটা ফেরত এলো?Double-free-এর পরে free-list একটা 2-cycle হয়ে গিয়েছিল: A(fd=B) -> B(fd=A) -> A(fd=B) -> ...। alloc() সবসময় বর্তমান head পপ করে আর তার
fd অনুসরণ করে পরবর্তী head ঠিক করে — তাই ক্রম হলো head=A (pop, নতুন
head=B) → head=B (pop, নতুন head=A, কারণ B-র fd আবার A-কেই দেখাচ্ছিল)
→ head=A (আবার পপ)। এই ক্রমটাই সরাসরি cycle-এর গঠন থেকে predictable —
এলোমেলো না।
4Build সেকশনের GenerationGuardedAllocator-এ একটা মাত্র লাইন
(chunk.generation += 1) UAF read আর double-free — দুইটা ভিন্ন bug-ই
ব্লক করে দিলো। এই একটা লাইন কীভাবে দুটোকেই কভার করছে?
যুক্তি
GenerationGuardedAllocator-এ একটা মাত্র লাইন
(chunk.generation += 1) UAF read আর double-free — দুইটা ভিন্ন bug-ই
ব্লক করে দিলো। এই একটা লাইন কীভাবে দুটোকেই কভার করছে?দুটো বাগেরই মূল কারণ আসলে একই: allocator-এর কাছে “এই pointer এখনো বৈধ কিনা” যাচাই করার কোনো তথ্য ছিল না, শুধু ঠিকানা। Generation counter সেই হারিয়ে-যাওয়া তথ্যটা যোগ করে — প্রতিটা handle নিজের সাথে “কোন generation-এ জন্ম নিয়েছি” বহন করে, আর allocator dereference/free-এর সময় সেটা current generation-এর সাথে মেলায়। Stale handle (UAF read) আর stale handle দিয়ে free() (double-free) — দুটোই একই যাচাইয়ে ধরা পড়ে যায়, কারণ দুটোরই মূল লক্ষণ একটাই: handle-এর generation বর্তমান chunk-এর generation-এর চেয়ে পুরনো।
5Realworld সেকশনে বলা হয়েছে Linux kernel CONFIG_SLAB_FREELIST_HARDENED
ব্যবহার করে free-list pointer-কে একটা random cookie দিয়ে XOR করে রাখে।
এই মিটিগেশন আজকের কোন নির্দিষ্ট আক্রমণ-ধাপকে সরাসরি কঠিন করে তোলে?
প্রয়োগ
CONFIG_SLAB_FREELIST_HARDENED
ব্যবহার করে free-list pointer-কে একটা random cookie দিয়ে XOR করে রাখে।
এই মিটিগেশন আজকের কোন নির্দিষ্ট আক্রমণ-ধাপকে সরাসরি কঠিন করে তোলে?Experiment-এর ধাপ ৫-এর মতো attack-এ, attacker সরাসরি একটা freed chunk-এর
fd field-এ নিজের পছন্দের arbitrary ঠিকানা লিখে দিয়েছিল
(write_fd(b, FAKE_TARGET)), আর allocator সরাসরি সেই ঠিকানা বিশ্বাস
করে ফেরত দিয়েছিল। যদি fd field আসলে real_fd XOR secret_cookie
হিসেবে সংরক্ষিত থাকত, attacker secret_cookie না জেনে সরাসরি একটা
বৈধ-দেখতে fd value লিখতে পারত না — attacker-এর লেখা raw মান
decode হয়ে একটা এলোমেলো, predictable-নয় ঠিকানায় পরিণত হতো, যা
প্রায় নিশ্চিতভাবেই crash ঘটাবে, নির্দিষ্ট attacker-নির্বাচিত ঠিকানা
ফেরত দেবে না।
6একটা দীর্ঘ-চলা (long-running) C++ network server ডিজাইন করছেন, যেখানে
প্রতিটা connection-এর জন্য একটা object তৈরি হয় আর connection বন্ধ হলে
destroy হয় — কিন্তু কিছু callback/timer এখনও পুরনো connection object-এর
raw pointer ধরে রাখতে পারে। তিনটা mitigation স্তর (nulling, hardened
allocator, language-level ownership) থেকে কোনটা বা কোন সমন্বয় বাছবেন,
আর কেন শুধু একটা যথেষ্ট না হতে পারে?
ডিজাইন
বাস্তব production system-এ সাধারণত তিনটা স্তরই একসাথে দরকার, কারণ
প্রতিটার সুরক্ষার পরিসর ভিন্ন। Nulling সস্তা কিন্তু শুধু single-owner
case-এ কাজ করে — callback/timer-এর আলাদা কপি তখনও dangling থাকবে, তাই
একা যথেষ্ট না। এখানে বাস্তব সমাধান হলো ownership নিজেই redesign করা
— raw pointer-এর বদলে std::shared_ptr/std::weak_ptr (C++-এ) ব্যবহার
করা, যেখানে callback একটা weak_ptr রাখে আর ব্যবহারের আগে lock()
করে যাচাই করে object এখনও জীবিত কিনা — এটা কার্যকরভাবে আজকের
generation-tag ধারণারই একটা প্রোডাকশন-গ্রেড রূপ। যদি নতুন কোড লেখার
সুযোগ থাকে, Rust-এ পুনর্লিখন (বা নতুন module Rust-এ লেখা, FFI দিয়ে
integrate করা) সবচেয়ে শক্তিশালী গ্যারান্টি দেয় — borrow checker
compile-time-এই এই পুরো bug-class বাতিল করে দেয়। কিন্তু legacy
C++ codebase-এ পুরো rewrite প্রায়ই অবাস্তব, তাই hardened
allocator/sanitizer (production build-এ ASan না হলেও অন্তত staging-এ,
আর Chrome-স্টাইল MiraclePtr-জাতীয় runtime check) একটা বাস্তবসম্মত
মধ্যবর্তী স্তর হিসেবে যোগ করা উচিত — কোনো একটা স্তর “সম্পূর্ণ সমাধান” না,
প্রতিটা defense-in-depth-এর একটা স্তর মাত্র।
এরপর কী
এরপর কী
আজ দেখলাম heap corruption সবসময় একটা lifetime-bug (কে কখন কোন মেমরির
মালিক) দিয়ে শুরু হয় না — কখনো কখনো এটা আরও গভীর, আরও নীরব একটা জায়গা
থেকে আসে। Use-after-free আর double-free দুটোরই একটা লুকানো পূর্বশর্ত
আছে যেটা আমরা আজ প্রশ্নই করিনি: malloc(n)-এ পাঠানো n-টা সংখ্যা কি
আদৌ সঠিক? যদি সেই n নিজেই একটা ভুল হিসাব থেকে আসে — বিশেষ করে যদি
সেটা কোনো গণনার ফলাফল যা silently wrap around করে ফেলেছে (একটা
বিশাল ধনাত্মক সংখ্যা হওয়ার কথা ছিল, কিন্তু integer-এর সীমার বাইরে গিয়ে
হঠাৎ একটা ছোট, বা এমনকি ঋণাত্মক সংখ্যায় পরিণত হয়েছে) — তাহলে পুরো
allocation-টাই ভুল আকারে হয়ে যায়, এমনকি malloc() কল করার আগেই।
পরের লেসনে সেই এক ধাপ নিচের সমস্যা: integer overflow। দেখব কীভাবে
একটা নিরীহ-দেখতে size1 * size2 বা count + header_size-জাতীয় হিসাব
নীরবে wrap করে একটা ছোট buffer allocate করিয়ে ফেলতে পারে, যেখানে
প্রোগ্রামার একটা বড় buffer আশা করছিল — আর কীভাবে এই একটা সংখ্যাগত ভুলই
পরে buffer overflow, use-after-free, আর double-free-এর মতো আজকের সব
পরিচিত বাগকে ট্রিগার করার দরজা খুলে দিতে পারে।
আরও পড়ুন
- CWE-416: Use After Free — MITRE — Common Weakness Enumeration · Use-after-free-এর আনুষ্ঠানিক সংজ্ঞা, সাধারণ consequence (crash, info-leak, code execution), আর mitigation-এর তালিকা
- CWE-415: Double Free — MITRE — Common Weakness Enumeration · Double-free-এর আনুষ্ঠানিক সংজ্ঞা — একই pointer দুইবার free() করার ফলাফল আর কেন এটা আলাদা CWE হিসেবে ট্র্যাক হয়
- Memory Safety — The Chromium Projects — Chrome Security Team · Chrome-এর high/critical-severity security bug-এর প্রায় ৭০% memory-safety issue, আর তার একটা বড় অংশ use-after-free — এই লেসনের realworld অংশের মূল সংখ্যাগত উৎস
- Trends, challenges, and strategic shifts in the software vulnerability mitigation landscape — Matt Miller — Microsoft Security Response Center, BlueHat IL 2019 · ১২ বছরের MSRC-assigned CVE বিশ্লেষণ — প্রায় ৭০% memory-safety issue; এই প্যাটার্ন কেন structural language-level fix-এর দিকে ঠেলে দিচ্ছে তার মূল উপস্থাপনা