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

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।

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

  • heap allocator-এর free-list/chunk model recap করে বুঝতে পারবেন কেন free() করা মেমরি সত্যিকারের অর্থে 'ফাঁকা' হয় না, শুধু 'পুনর্ব্যবহারযোগ্য' চিহ্নিত হয় — আর এই একটা তথ্যই use-after-free-এর গোটা ভিত্তি
  • একটা toy allocator নিজে চালিয়ে দেখাতে পারবেন কীভাবে একটা stale pointer free হওয়া chunk-এর নতুন, attacker-controlled মালিকের ডেটা পড়ে ফেলে — hand-wave না, সংখ্যায়
  • double-free কীভাবে free-list-এর linked structure-কে cycle/aliasing-এ corrupt করে, আর সেই একই কৌশল বাড়িয়ে কীভাবে একটা 'write-what-where' primitive (malloc attacker-নির্ধারিত arbitrary address ফেরত দেওয়া) বানানো যায় তা ব্যাখ্যা করতে পারবেন
  • কেন memory-safety bug, বিশেষত use-after-free, আজকের browser আর kernel-এ সবচেয়ে বড় vulnerability class — Chromium আর MSRC-এর প্রকাশিত সংখ্যা দিয়ে যুক্তি দিতে পারবেন
  • hardened allocator, freed-pointer nulling, আর Rust-এর ownership/borrow-checker মডেল — তিনটা ভিন্ন স্তরের mitigation-এর মধ্যে পার্থক্য করে কোনটা 'সাহায্যকারী প্র্যাকটিস' আর কোনটা 'structural fix' তা যুক্তি দিয়ে বলতে পারবেন

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

আগে এটা বুঝি

আগের লেসনে 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-freeDouble-free
কী corrupt হয়pointer-এর পেছনের ডেটাallocator-এর metadata (free-list linkage)
ন্যূনতম bugএকটা stale read/writeএকটা দ্বিতীয়, অপ্রয়োজনীয় free() কল
সাধারণ outcomeinfo-leak, type confusionheap corruption, aliasing, arbitrary allocation
CWECWE-416CWE-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-এর ডেটার ওপর কাজ করতে শুরু করে।

Use-after-free-এর anatomy — কোন স্তরে কী ভাঙে
  1. application logicএকটা object free হয়ে গেছে, কিন্তু কোনো callback/cache/iterator-এ তার pointer-এর কপি রয়ে গেছে
  2. language/compilerC/C++-এ pointer lifetime যাচাই করার কোনো বাধ্যতামূলক mechanism নেই — programmer-এর দায়িত্ব
  3. allocator (chunk/free-list)free() শুধু bookkeeping বদলায়; ডেটা মোছে না, pointer-কে জানায়ও না
  4. attacker-controlled reallocationএকই size-class-এর একটা নতুন allocation একই ঠিকানা পুনর্ব্যবহার করে, attacker-নির্ধারিত ডেটাসহ

উদাহরণ

একটা concrete timeline — কেন এই বাগ “মাঝে মাঝে কাজ করে না” রকমের বিরক্তিকর

Use-after-free-এর একটা কুখ্যাত বৈশিষ্ট্য: exploitability সম্পূর্ণভাবে নির্ভর করে টাইমিং-এর ওপর — free আর reuse-এর মাঝখানে ঠিক কী allocate হচ্ছে তার ওপর। নিচের timeline-টা একটা বাস্তব browser-স্টাইল দৃশ্য (একটা DOM node-এর callback থেকে ধরে):

সময়ঘটনাheap-এর অবস্থা
t0Node* n = new Node(...)chunk allocate, n বৈধ
t1কোনো event listener n-এর pointer একটা callback queue-তে কপি করে রাখলদ্বিতীয় (দুর্বল না, কিন্তু “লুকানো”) reference তৈরি হলো
t2delete n; মূল owner object destroy করলchunk free-list-এ ফিরে গেল — কিন্তু callback queue-র কপি এখনো আছে
t3attacker একটা নতুন, একই-size-এর JS object তৈরি করাল (heap grooming)নতুন object পুরনো n-এর ঠিকানাই পেয়ে গেল
t4event 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-এর আগে ও পরে — free-list-এর singly-linked গঠন কীভাবে cycle তৈরি করে।
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।
Write-what-where — UAF দিয়ে free-list-এর fd-পয়েন্টার corrupt করলে malloc() attacker-নির্ধারিত arbitrary ঠিকানা ফেরত দেয়।

এই দুইটা ছবিই এক জিনিসের দুই ধাপ — প্রথমটা দেখায় double-free কীভাবে allocator-কে বিভ্রান্ত করে (aliasing), দ্বিতীয়টা দেখায় সেই বিভ্রান্তি কীভাবে নিয়ন্ত্রিত করা যায় (attacker-নির্ধারিত ঠিকানা ফেরত পাওয়া)। পরের অংশে দুটোই আসলে চালিয়ে দেখানো হবে।

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

EXPERIMENT

Double-free থেকে write-what-where — সংখ্যায়, ধাপে ধাপে

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

নিজে বানান

BUILD IT

GenerationGuard — free()-কে 'ভুলে-যাওয়া' থেকে 'মনে-রাখা' বানানো

Python · ●●●●○
  1. প্রতিটা chunk-এ একটা generation counter যোগ করুন — শুরুতে 0
  2. alloc()-এর ফেরত-দেওয়া handle-এ (addr, generation) দুটোই বহন করান, শুধু addr না
  3. free()-এ generation +১ বাড়ান — chunk পুনর্ব্যবহারযোগ্য থাকুক, কিন্তু পুরনো handle-এর generation এখন stale হয়ে যাক
  4. read()/free() দুটোতেই handle.generation বনাম chunk.generation মিলিয়ে দেখুন — না মিললে exception, silent bug নয়
  5. দেখান একই 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 হয়ে নয়।

নিজে বাড়ান:

  1. free_list-কে LIFO stack না রেখে, address-অনুযায়ী sorted রাখলে কি aliasing-ধাঁচের bug কোনোভাবে সহজ হয় বা কঠিন হয়? যুক্তি দিন
  2. read()-এর মতো একটা write(handle, value) মেথড যোগ করুন — একই generation-check প্রযোজ্য কিনা যাচাই করুন
  3. এই 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-এর মনে রাখার ওপর নির্ভর না করে।

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

1

Use-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 ছাড়াই।

2

Concept সেকশনের টেবিলে দেখা গেছে — সবচেয়ে বিপজ্জনক 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 করা অনেক কঠিন।

3

Experiment-এর ধাপ ৪-এ 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 — এলোমেলো না।

4

Build সেকশনের 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-এর চেয়ে পুরনো।

5

Realworld সেকশনে বলা হয়েছে Linux kernel 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-এর দিকে ঠেলে দিচ্ছে তার মূল উপস্থাপনা