Stack Canary, ASLR, NX Bit, CFI — Mitigation-এর চার স্তর
Mitigations: Stack Canaries, ASLR, NX Bit, and Control-Flow Integrity
Stack canary return address ছোঁয়ার আগেই overflow ধরে, ASLR ঠিকানা-অনুমানকে সংখ্যাগতভাবে কঠিন করে, NX bit injected shellcode বন্ধ করে, আর CFI প্রতিটা জাম্প-টার্গেট যাচাই করে — প্রতিটাই আগের লেসনের একটা নির্দিষ্ট ধাপ থামায়, কোনোটাই একা যথেষ্ট না, কিন্তু চারটা একসাথে স্ট্যাক করলে attacker-কে একইসাথে একাধিক স্বাধীন bypass চেইন করতে হয়।
আগে এটা বুঝি
আগের লেসনের শেষ প্যারাগ্রাফে চারটা নাম রেখে দেওয়া হয়েছিল, ব্যাখ্যা ছাড়াই: stack canary, ASLR, NX bit, আর CFI। আজ সেই প্রতিশ্রুতি পূরণ হবে — কিন্তু একটা নির্দিষ্ট শর্তে। প্রতিটা mitigation-কেই আমরা একই তিন-ধাপি প্যাটার্নে পরীক্ষা করব: (১) এটা ঠিক কী মেকানিজমে কাজ করে, (২) আগের লেসনের ঠিক কোন ধাপটা এটা বন্ধ করে দেয়, আর (৩) — সবচেয়ে গুরুত্বপূর্ণ — এটা ঠিক কী বন্ধ করে না, আর attacker-এর পরের পদক্ষেপ কী।
এই তৃতীয় প্রশ্নটাই আজকের লেসনের আসল হাড়। একটা mitigation-কে “সমাধান” বলে ঘোষণা করা সবচেয়ে সহজ ভুল — বাস্তবে প্রতিটা mitigation একটা নির্দিষ্ট, সংকীর্ণ আক্রমণ-পৃষ্ঠ (attack surface) বন্ধ করে, আর নিরাপত্তা গবেষকদের পরের কাজ হয়ে দাঁড়ায় সেই বন্ধ-দরজার পাশের জানালাটা খুঁজে বের করা। NX bit-এর পরে ঠিক এটাই ঘটেছিল — Aleph One-এর ১৯৯৬-এর shellcode-injection কৌশল রাতারাতি অকেজো হয়ে গেল, আর ২০০৭-এ Hovav Shacham দেখালেন injection ছাড়াই, শুধু existing code-এর টুকরো জোড়া দিয়েই, একই ক্ষমতা ফিরে পাওয়া সম্ভব — Return-Oriented Programming।
মূল ধারণা
চারটা mitigation, এক নজরে
আগের লেসনে দেখা আক্রমণটার একটা নির্দিষ্ট, রৈখিক ধারা ছিল: (১) একটা
unbounded copy বাফার ওভারফ্লো করায়, (২) সেই overflow saved return
address-কে ওভাররাইট করে, (৩) ret অন্ধভাবে সেই attacker-লিখিত
ঠিকানায় জাম্প করে, (৪) সেই ঠিকানায় যদি attacker-এর injected shellcode
থাকে, arbitrary code execution ঘটে। চারটা mitigation এই একই ধারার
চারটা ভিন্ন বিন্দুতে আক্রমণ করে:
| Mitigation | কোন ধাপ টার্গেট করে | কবে চালু হলো (ইতিহাস) |
|---|---|---|
| Stack canary | ধাপ ২ — return address-এ পৌঁছানোর আগেই overflow ধরে | Cowan et al., StackGuard, ১৯৯৮ |
| ASLR | ধাপ ৩ — attacker-এর হার্ডকোড করা ঠিকানাকেই ভুল করে দেয় | PaX Team, ~২০০১; mainstream Linux/Windows, ~২০০৫-২০০৭ |
| NX bit / DEP | ধাপ ৪ — buffer-এর ভেতরে বসানো shellcode execute হতে দেয় না | PaX Team (PAGEEXEC), ~২০০০; হার্ডওয়্যার NX bit, AMD ২০০৩ / Intel ২০০৪ |
| CFI | ধাপ ৩+৪ — যেকোনো indirect জাম্পের টার্গেটকেই যাচাই করে, শুধু return address-এ না | Abadi, Budiu, Erlingsson, Ligatti — ২০০৫; হার্ডওয়্যার-সমর্থিত রূপ (Intel CET, ARM BTI) ~২০১৯-২০২০ |
লক্ষ্য করুন ক্রমটা — প্রতিটা mitigation ঠিক আগেরটার একটা “ফাঁকা জায়গা” ভরাট করতে এসেছে, কালানুক্রমিকভাবেও। StackGuard-এর পরে attacker-রা এমন বাগ খুঁজতে শুরু করল যেখানে canary bypass করা যায় (বা যেখানে overflow সরাসরি heap-এ, canary যেখানে নেইই)। ASLR-এর পরে তথ্য-ফাঁস (information leak) বাগ-শ্রেণির গুরুত্ব হঠাৎ বেড়ে গেল। NX-এর পরে জন্ম নিল ROP। আর ROP-এর উত্তরে জন্ম নিল CFI। এই পুরো ইতিহাসটাই একটা অস্ত্র-প্রতিযোগিতা (arms race) — আজকের লেসন সেই প্রতিযোগিতার চারটা রাউন্ড, ক্রমানুসারে।
ভেতরে কী ঘটছে
Stack canary — return address-এর সামনে একটা tripwire
আগের লেসনের assembly-recap মনে করুন: name[8]-এর overflow প্রথমে
saved-rbp ছুঁয়ে, তারপর return address-এ পৌঁছায়। Cowan-এর ১৯৯৮-এর
StackGuard-এর মূল আইডিয়া বিব্রতকরভাবে সহজ — যদি overflow সবসময় এই একই
রৈখিক পথে “হাঁটে,” তাহলে সেই পথের মধ্যে, buffer-এর ঠিক পরে আর return
address-এর ঠিক আগে, একটা তৃতীয় জিনিস বসিয়ে দাও: একটা গোপন সংখ্যা, যার
মান শুধু compiler-generated prologue আর epilogue জানে। Function শুরু
হওয়ার সময় (prologue) সেই সংখ্যাটা frame-এ কপি হয়; function ফেরত যাওয়ার
ঠিক আগে (epilogue, ret-এর ঠিক আগে) সেই সংখ্যাটা আবার পড়ে আসল মানের
সাথে তুলনা হয়। মিলে গেলে ret চলে; না মিললে প্রোগ্রাম সাথে সাথে
abort করে — ret কখনো execute হয় না।
নতুন frame layout তাই আগের লেসনের তিন-অংশের বদলে চার অংশ:
rbp+16 → return address ← আগের মতোই, কিন্তু এখন আরেক ধাপ দূরে
rbp+8 → saved rbp
rbp+0 → canary ← ★ নতুন — buffer আর saved-rbp-এর মাঝে বসানো
rbp-8 → local variable ১ ← buffer এখান থেকে শুরু, ঠিক আগের মতোইগুরুত্বপূর্ণ: canary বসে বাফারের ঠিক পরে, saved-rbp/return-address-এর ঠিক আগে — অর্থাৎ একটা রৈখিক overflow return address-এ পৌঁছানোর আগেই বাধ্যতামূলকভাবে canary-কে প্রথমে ছুঁতে হয়। এটাই পুরো কৌশলের ভিত্তি: canary নিজে কোনো মূল্যবান ডেটা রক্ষা করছে না, এটা শুধু একটা সংকেতদাতা — “কেউ যদি এই বিন্দু পার হয়ে গেছে, নিশ্চিতভাবে ধরে নাও return address-ও বিপদে আছে।”
Canary কী থামায় না
তিনটা নির্দিষ্ট ফাঁক মনে রাখা জরুরি, কারণ প্রতিটাই বাস্তব exploitation-এ নিয়মিত ব্যবহৃত হয়:
১. Heap overflow। Canary শুধু stack frame-এ বসে — compiler-এর
-fstack-protector-জাতীয় ফ্ল্যাগ শুধু local buffer-ওয়ালা function-এর
prologue/epilogue-এ canary-চেক inject করে। একটা malloc()-করা heap
chunk-এর পাশে বসে থাকা আরেকটা heap chunk-এর data — সেখানে কোনো canary
নেই, কারণ compiler heap allocation নিজে দেখেই না, সেটা runtime-এর একটা
ফাংশন কল মাত্র। তাই একটা heap buffer overflow ক্যানারি-সুরক্ষিত সিস্টেমেও
সম্পূর্ণ অবাধে চলতে পারে।
২. Arbitrary-write bug। Canary ধরে নেয় আক্রমণটা একটা রৈখিক overflow
— buffer থেকে শুরু করে ধারাবাহিকভাবে উপরের দিকে এগোচ্ছে। কিন্তু কিছু
bug-শ্রেণি (format-string %n, একটা ভুল-গণনা করা array index, একটা
type-confused pointer) সরাসরি যেকোনো ঠিকানায় লিখতে পারে — সেই লেখাটা
canary-র উপর দিয়ে “হাঁটে না,” সরাসরি return address-এ গিয়ে বসে। Canary
এই ধরনের bug সম্পর্কে কিছুই জানে না, কারণ কখনো touch-ই হয় না।
৩. Canary leak। যদি একটা আলাদা bug (প্রায়ই format-string %x/%s)
attacker-কে stack memory পড়তে দেয়, সেই একই read canary-র বর্তমান মানও
ফাঁস করে দিতে পারে — আর একবার মান জানা হয়ে গেলে, attacker নিজের overflow
payload-এ হুবহু সেই মান বসিয়ে দিতে পারে, canary-চেক পাস করিয়ে।
Experiment সেকশনে এই তিনটার প্রথমটা (heap-এ কোনো সুরক্ষা নেই) সরাসরি চালিয়ে দেখানো হবে।
উদাহরণ
ASLR — এন্ট্রপি সংখ্যায়
Canary return-address-এ পৌঁছানো আটকায়। কিন্তু ধরা যাক canary-ই নেই, বা
attacker কোনোভাবে bypass করে ফেলেছে (heap bug, বা leak) — attacker-কে
তখনও একটা জিনিস জানতে হয়: ঠিক কোন ঠিকানায় shellcode বা gadget বসানো
আছে। আগের লেসনের experiment-এ ATTACKER_RET = 0x0000555555de0d0d একটা
হার্ডকোড করা ধ্রুবক ছিল — বাস্তব আক্রমণেও attacker-কে একটা নির্দিষ্ট
সংখ্যা বেছে নিতেই হয়। ASLR (Address Space Layout Randomization)-এর
আইডিয়া: প্রতিবার প্রোগ্রাম চালু হওয়ার সময় (execve), stack, heap, আর
shared library-গুলোর base address এলোমেলোভাবে বেছে নাও — তাহলে গতবারের
হার্ডকোড করা ঠিকানা এবারের প্রসেসে সঠিক হওয়ার কোনো নিশ্চয়তা থাকে না।
Entropy এখানে মূল সংখ্যা — কতগুলো bit সত্যিই এলোমেলো, তার ওপর নির্ভর
করে সম্ভাব্য base address-এর সংখ্যা (2^bits), আর সেখান থেকেই একটা
random guess সঠিক হওয়ার সম্ভাবনা (1 / 2^bits)। PaX-এর নিজস্ব ASLR
দলিলে architecture আর region অনুযায়ী entropy বিটের সংখ্যা নথিভুক্ত —
৩২-বিট আর ৬৪-বিট সিস্টেমের মধ্যে পার্থক্যটা নাটকীয়, কারণ address space-এর
আকারই মৌলিকভাবে আলাদা। নিচের হিসাবে দুইটা commonly-cited figure নেওয়া
হলো: ৩২-বিট Linux mmap/heap region-এর জন্য ৮ বিট এন্ট্রপি, আর ৬৪-বিট
Linux mmap region-এর জন্য ২৮ বিট (বাস্তব সংখ্যা kernel সংস্করণ আর
architecture-ভেদে সামান্য বদলায়, কিন্তু এই দুইটা ব্যাপকভাবে উদ্ধৃত মান)।
# aslr_entropy.py -- concrete entropy math: given N bits of ASLR randomization,
# how many guesses (and how much wall-clock time) does brute force need?
import math
def stats(bits, label, attempts_per_sec):
space = 2 ** bits
# expected guesses to first success, sampling with replacement (attacker
# can always retry): geometric distribution, mean = space, median ~= 0.693*space
median_guesses = math.ceil(math.log(0.5) / math.log(1 - 1/space)) if space > 1 else 1
median_seconds = median_guesses / attempts_per_sec
print(f"{label}")
print(f" entropy = {bits} bits")
print(f" address space size = 2^{bits} = {space:,}")
print(f" P(single guess wins) = 1/{space:,} = {1/space:.3e}")
print(f" median guesses to 50% success = {median_guesses:,}")
print(f" at {attempts_per_sec:,}/sec -> median time to 50% success = {median_seconds:,.2f} sec "
f"({median_seconds/60:,.2f} min, {median_seconds/3600:,.4f} hr)")
print()
print("== illustrative attack rate: 100 attempts/sec (typical remote, fork-retry) ==\n")
stats(8, "32-bit Linux mmap/heap ASLR (commonly cited entropy)", 100)
stats(28, "64-bit Linux mmap ASLR (commonly cited entropy)", 100)
print("== same math, local attacker: 1,000,000 attempts/sec ==\n")
stats(8, "32-bit Linux mmap/heap ASLR (commonly cited entropy)", 1_000_000)
stats(28, "64-bit Linux mmap ASLR (commonly cited entropy)", 1_000_000)
print("== one information leak collapses BOTH cases to bits=0 ==\n")
stats(0, "post-leak: exact base address known", 100)প্রকৃত চালিয়ে পাওয়া output (deterministic — হুবহু এটাই আসবে):
== illustrative attack rate: 100 attempts/sec (typical remote, fork-retry) ==
32-bit Linux mmap/heap ASLR (commonly cited entropy)
entropy = 8 bits
address space size = 2^8 = 256
P(single guess wins) = 1/256 = 3.906e-03
median guesses to 50% success = 178
at 100/sec -> median time to 50% success = 1.78 sec (0.03 min, 0.0005 hr)
64-bit Linux mmap ASLR (commonly cited entropy)
entropy = 28 bits
address space size = 2^28 = 268,435,456
P(single guess wins) = 1/268,435,456 = 3.725e-09
median guesses to 50% success = 186,065,280
at 100/sec -> median time to 50% success = 1,860,652.80 sec (31,010.88 min, 516.8480 hr)
== same math, local attacker: 1,000,000 attempts/sec ==
32-bit Linux mmap/heap ASLR (commonly cited entropy)
entropy = 8 bits
address space size = 2^8 = 256
P(single guess wins) = 1/256 = 3.906e-03
median guesses to 50% success = 178
at 1,000,000/sec -> median time to 50% success = 0.00 sec (0.00 min, 0.0000 hr)
64-bit Linux mmap ASLR (commonly cited entropy)
entropy = 28 bits
address space size = 2^28 = 268,435,456
P(single guess wins) = 1/268,435,456 = 3.725e-09
median guesses to 50% success = 186,065,280
at 1,000,000/sec -> median time to 50% success = 186.07 sec (3.10 min, 0.0517 hr)
== one information leak collapses BOTH cases to bits=0 ==
post-leak: exact base address known
entropy = 0 bits
address space size = 2^0 = 1
P(single guess wins) = 1/1 = 1.000e+00
median guesses to 50% success = 1
at 100/sec -> median time to 50% success = 0.01 sec (0.00 min, 0.0000 hr)সংখ্যাগুলো নিজেই গল্পটা বলে দিচ্ছে। ৩২-বিট সিস্টেমে, প্রতি সেকেন্ডে মাত্র ১০০ চেষ্টায়, মাত্র ১.৭৮ সেকেন্ডেই অর্ধেক সম্ভাবনা attacker-এর পক্ষে চলে আসে — ASLR প্রায় কোনো বাধাই না। ৬৪-বিট সিস্টেমে একই আক্রমণ-হারে লাগবে ৫১৬ ঘণ্টার বেশি — কিন্তু attacker যদি local, বা কোনো দ্রুত brute-force চ্যানেল (fork-and-retry-জাতীয় কৌশল ব্যবহার করে) পায় এবং হার বেড়ে দাঁড়ায় সেকেন্ডে দশ লাখ চেষ্টায়, সেই একই ৬৪-বিট এন্ট্রপি মাত্র ৩ মিনিটে ভেঙে পড়ে। এন্ট্রপি সংখ্যা যতই বড় হোক, এটা সবসময় আক্রমণের হারের সাপেক্ষে পড়তে হবে — একটা স্থির সংখ্যা একা কিছু বলে না।
ছবিতে
NX bit — buffer-এর ভেতরের shellcode এখন অকার্যকর
attacker_input = [ shellcode bytes ......... ][ saved-rbp filler ][ return_addr = নিজের ঠিকানা ]
▲ │
└──────────── ret এই ঠিকানায় জাম্প করে ◄─────────────────┘
NX bit ছাড়া: CPU সেই ঠিকানা থেকে fetch করে byte-গুলোকে instruction হিসেবে
execute শুরু করে — আগের লেসনের ফলাফল, arbitrary code execution।
NX bit-সহ: সেই একই মেমরি-পাতা (buffer যেখানে বসে) হার্ডওয়্যার পেজ-টেবিলে
"non-executable" হিসেবে চিহ্নিত। CPU সেখান থেকে instruction fetch করার
চেষ্টা করা মাত্রই একটা page fault (general protection fault) তোলে —
`ret` যদিও একই ভুল ঠিকানায় জাম্প করে, সেখানে execution আর শুরুই হয় না।ret এখানেও নিজের সংজ্ঞা মেনেই ভুল ঠিকানায় জাম্প করছে — NX bit ret-কে
বদলায় না, এটা বদলায় সেই ঠিকানার মেমরি-পাতার নিজের অনুমতি। Data (স্ট্যাক,
হিপ) আর code (.text সেগমেন্ট) — এই দুইটা এখন হার্ডওয়্যার-স্তরে সম্পূর্ণ
পৃথক ভূমিকা: হয় লেখা যাবে, নয় execute করা যাবে, দুটোই একসাথে না — এই
নীতির প্রচলিত নাম W^X (“write XOR execute”)। শুধু হার্ডকোড করা
shellcode বসিয়ে দেওয়ার কৌশল, যেটা আগের লেসনের পুরো ভিত্তি ছিল, এখন আর
কাজ করে না।
Return-Oriented Programming — কোড না বসিয়েই কোড execute
NX bit-এর সহজ উত্তর নতুন কোড বসানো বন্ধ করেছে। কিন্তু Shacham-এর
২০০৭-এর অন্তর্দৃষ্টি: attacker-এর নতুন কোড বসানোরই দরকার নেই — প্রোগ্রামের
নিজস্ব .text সেগমেন্টে, আর সেটা যেসব shared library link করে (libc-সহ)
তার মধ্যেই, হাজার হাজার ছোট ছোট instruction-sequence ইতিমধ্যে বিদ্যমান,
প্রতিটাই কোনো একটা ret-এ শেষ হয় — এদের বলা হয় gadget। যদি
attacker একটা করে gadget-এর ঠিকানা স্ট্যাকে সাজিয়ে রাখে, প্রতিটা gadget
নিজের ret-এ পৌঁছে স্ট্যাক-টপ থেকে পরের ঠিকানা পড়ে পরের gadget-এ
জাম্প করবে — একটার পর একটা, ডমিনো-র মতো।
attacker স্ট্যাকে সাজায় শুধু ঠিকানার একটা তালিকা — কোনো নতুন কোড না:
return_addr slot 1 ──► 0x...aa11 pop rdi; ret (gadget ১ — libc-এই আগে থেকে ছিল)
return_addr slot 2 ──► 0x...bb22 pop rsi; ret (gadget ২)
return_addr slot 3 ──► 0x...cc33 mov [rsi], rdi; ret (gadget ৩)
return_addr slot 4 ──► 0x...dd44 call system (libc-এর নিজস্ব ফাংশন)
প্রতিটা gadget মাত্র কয়েকটা instruction execute করেই নিজের `ret`-এ
পৌঁছায় — আর সেই `ret` স্ট্যাক-টপ থেকে *পরের* attacker-লিখিত ঠিকানা পড়ে
পরের gadget-এ জাম্প করে। কোনো byte-ই নতুন execute-করা কোড না — সবই
আগে থেকে মেমরিতে থাকা, বৈধ, executable instruction। NX bit প্রতিটা
`ret`-কেই অনুমতি দেয়, কারণ প্রতিটা টার্গেটই আসলেই executable — শুধু
ক্রম আর ইনপুট attacker-এর নিয়ন্ত্রণে।Shacham দেখালেন libc-এর মতো একটা বড়, সাধারণ library-তে যথেষ্ট gadget
পাওয়া যায় যে সেগুলো জুড়ে Turing-complete যেকোনো গণনা করা সম্ভব — NX
bit শুধু injection বন্ধ করেছিল, reuse বন্ধ করেনি।
CFI — টার্গেটকেই যাচাই করা
Control-Flow Integrity-এর মূল আইডিয়া: ret (বা যেকোনো indirect
call/jmp) execute হওয়ার ঠিক আগে, শুধু “স্ট্যাক-টপে যা আছে সেটাই বিশ্বাস
করো” না — টার্গেট ঠিকানাটা আসলেই একটা বৈধ control-flow টার্গেট কিনা
যাচাই করো। “বৈধ” মানে কী তা নির্ভর করে বাস্তবায়নের ওপর — forward-edge
CFI (indirect call/jmp) টার্গেটকে কোনো ফাংশনের ঘোষিত এন্ট্রি-পয়েন্টের
তালিকার সাথে মেলায়; backward-edge CFI (return address) একটা আলাদা,
হার্ডওয়্যার-সুরক্ষিত shadow stack-এ রাখা কপির সাথে মিলিয়ে দেখে —
attacker যদি সাধারণ স্ট্যাকের return address বদলে দেয়, shadow stack-এর
কপি অপরিবর্তিত থাকে, mismatch ধরা পড়ে।
- buffer overflow ঘটেআগের লেসনের bug — কোনো mitigation এখানে কিছু করে না, overflow ঘটেই যায়
- stack canary চেকoverflow যদি return address পর্যন্ত পৌঁছায়, canary প্রথমে corrupt হয় — ret চালানোর আগেই abort
- ASLRcanary bypass হলেও, attacker-এর হার্ডকোড করা target ভুল ঠিকানা — যদি না base leak হয়েছে
- NX bit / DEPঠিকানা সঠিক হলেও, buffer-এর ভেতরে বসানো shellcode আর execute হয় না
- CFIattacker ROP-এ সরে গেলেও, প্রতিটা জাম্প-টার্গেট বৈধ এন্ট্রি-পয়েন্ট/shadow-stack-এর সাথে মেলাতে হয়
নিজে চালিয়ে দেখুন
Canary সিমুলেশন — আগের লেসনের StackFrame-এ একটা tripwire যোগ করা
# canary_sim.py -- extends the StackFrame model from memory-safety-buffer-overflow.mdx:
# insert a canary slot directly above the vulnerable buffer, below saved-rbp/return-address.
import struct
class CanaryFrame:
"""same StackFrame shape as the previous lesson, with one addition: an
8-byte canary slot the compiler places directly above the local buffer --
guaranteeing any linear overflow touches the canary BEFORE it can reach
saved_rbp or return_address.
offset 0..7 -> buf (8 byte, local variable -- attacker's entry point)
offset 8..15 -> canary (8 byte, secret value, saved once at function entry)
offset 16..23 -> saved_rbp (8 byte)
offset 24..31 -> return_addr (8 byte)
"""
def __init__(self, legit_return, canary_value):
self.mem = bytearray(32)
self.canary_value = canary_value
self.mem[8:16] = struct.pack("<Q", canary_value)
self.mem[24:32] = struct.pack("<Q", legit_return)
@property
def canary(self):
return struct.unpack("<Q", self.mem[8:16])[0]
@property
def return_address(self):
return struct.unpack("<Q", self.mem[24:32])[0]
def vulnerable_copy(frame, data: bytes):
"""strcpy-faithful: no bounds check, writes from offset 0 onward -- identical
to the previous lesson's vulnerable_copy, just applied to a 32-byte frame now."""
for i, b in enumerate(data):
frame.mem[i] = b
def guarded_epilogue(frame):
"""what -fstack-protector inserts right before 'ret': compare the canary
against the value saved at function entry. Mismatch -> abort, ret never runs."""
if frame.canary != frame.canary_value:
raise RuntimeError(
f"*** stack smashing detected ***: canary mismatch "
f"(expected={frame.canary_value:#018x}, found={frame.canary:#018x}) -- abort()"
)
return frame.return_address # only reached if canary intact
PROCESS_CANARY = 0xdeadbeefcafe0000 # fixed here for reproducibility; a real canary is
# random per-process (from /dev/urandom at startup)
# and always carries a 0x00 byte to block string leaks
LEGIT_RETURN = 0x0000555555555169
ATTACKER_TARGET = 0x0000555555de0d0d
print(f"process canary (secret, saved once at entry) = {PROCESS_CANARY:#018x}")
print("\n--- scenario 1: normal-size write, canary untouched ---")
frame1 = CanaryFrame(LEGIT_RETURN, PROCESS_CANARY)
vulnerable_copy(frame1, b"A" * 8)
ret = guarded_epilogue(frame1)
print(f"epilogue passed, return_address = {ret:#018x} (canary intact)")
print("\n--- scenario 2: the EXACT 24-byte payload that fully hijacked the")
print(" previous lesson's (canary-less) frame -- replayed against this frame ---")
old_payload = b"A" * 8 + b"B" * 8 + struct.pack("<Q", ATTACKER_TARGET)
frame2 = CanaryFrame(LEGIT_RETURN, PROCESS_CANARY)
vulnerable_copy(frame2, old_payload)
print(f"canary after overflow = {frame2.canary:#018x} (matches secret? {frame2.canary == PROCESS_CANARY})")
print(f"return_address after overflow = {frame2.return_address:#018x} (still legit? {frame2.return_address == LEGIT_RETURN})")
try:
ret = guarded_epilogue(frame2)
print(f"epilogue passed -- should not happen! ret={ret:#018x}")
except RuntimeError as e:
print(f"BLOCKED: {e}")
print("\n--- scenario 3: attacker adapts to the new layout, aims 32 bytes at")
print(" return_address, but does NOT know PROCESS_CANARY -- guesses zero ---")
guessed_canary = 0x0000000000000000
adapted_payload = (b"A" * 8 + struct.pack("<Q", guessed_canary) +
b"C" * 8 + struct.pack("<Q", ATTACKER_TARGET))
frame3 = CanaryFrame(LEGIT_RETURN, PROCESS_CANARY)
vulnerable_copy(frame3, adapted_payload)
print(f"return_address after overflow = {frame3.return_address:#018x} (attacker-target? {frame3.return_address == ATTACKER_TARGET})")
try:
ret = guarded_epilogue(frame3)
print(f"epilogue passed -- hijack succeeded! ret={ret:#018x}")
except RuntimeError as e:
print(f"BLOCKED: {e}")
print("-> return_address WAS successfully overwritten this time (32-byte payload")
print(" reaches it), but the canary check still fires first and aborts before")
print(" 'ret' ever executes -- the overwritten value is never used.")
print("\n--- scenario 4: what a canary does NOT protect -- a heap-style region ---")
print(" (canaries are inserted only in STACK frames by -fstack-protector;")
print(" heap allocations get no canary at all)")
heap = bytearray(24)
struct.pack_into("<Q", heap, 16, 0x0000555555555169) # e.g. a function pointer living on the heap
def heap_vulnerable_copy(mem, data):
for i, b in enumerate(data):
mem[i] = b
heap_payload = b"X" * 16 + struct.pack("<Q", ATTACKER_TARGET)
heap_vulnerable_copy(heap, heap_payload)
corrupted_fp = struct.unpack_from("<Q", heap, 16)[0]
print(f"heap function-pointer after overflow = {corrupted_fp:#018x} (attacker-target? {corrupted_fp == ATTACKER_TARGET})")
print("-> corrupted silently. No canary exists here to check, no abort happens --")
print(" stack canary offers exactly zero protection outside the stack frame.")প্রকৃত চালিয়ে পাওয়া output (deterministic — হুবহু এটাই আসবে):
process canary (secret, saved once at entry) = 0xdeadbeefcafe0000
--- scenario 1: normal-size write, canary untouched ---
epilogue passed, return_address = 0x0000555555555169 (canary intact)
--- scenario 2: the EXACT 24-byte payload that fully hijacked the
previous lesson's (canary-less) frame -- replayed against this frame ---
canary after overflow = 0x4242424242424242 (matches secret? False)
return_address after overflow = 0x0000555555555169 (still legit? True)
BLOCKED: *** stack smashing detected ***: canary mismatch (expected=0xdeadbeefcafe0000, found=0x4242424242424242) -- abort()
--- scenario 3: attacker adapts to the new layout, aims 32 bytes at
return_address, but does NOT know PROCESS_CANARY -- guesses zero ---
return_address after overflow = 0x0000555555de0d0d (attacker-target? True)
BLOCKED: *** stack smashing detected ***: canary mismatch (expected=0xdeadbeefcafe0000, found=0x0000000000000000) -- abort()
-> return_address WAS successfully overwritten this time (32-byte payload
reaches it), but the canary check still fires first and aborts before
'ret' ever executes -- the overwritten value is never used.
--- scenario 4: what a canary does NOT protect -- a heap-style region ---
(canaries are inserted only in STACK frames by -fstack-protector;
heap allocations get no canary at all)
heap function-pointer after overflow = 0x0000555555de0d0d (attacker-target? True)
-> corrupted silently. No canary exists here to check, no abort happens --
stack canary offers exactly zero protection outside the stack frame.স্কেনারিও ২-এর ফলাফলটাই সবচেয়ে instructive: canary যোগ করার পরে,
আগের লেসনের সেই একই ২৪-byte payload আর return address-কেই ছুঁতে পারছে
না (still legit? True) — কারণ নতুন layout-এ canary এখন সেই জায়গায়
বসে যেখানে আগে saved-rbp ছিল, আর payload-এর দৈর্ঘ্য তার বেশি যায়নি।
তবু আক্রমণ ব্যর্থ হয়েছে, কারণ canary নিজেই ততক্ষণে corrupt হয়ে গেছে
(matches secret? False) — guarded_epilogue সেটা ধরে ফেলেছে, ret
পর্যন্ত পৌঁছানোর সুযোগই হয়নি। স্কেনারিও ৩ আরও কঠোর প্রমাণ দেয়: এবার
payload ৩২ byte, return address সত্যিই সম্পূর্ণ ওভাররাইট হয়েছে
(attacker-target? True) — তবু guarded_epilogue আটকে দিয়েছে, কারণ
attacker-এর canary-অনুমান (0x0) আসল secret-এর সাথে মেলেনি। স্কেনারিও
৪ দেখায় ঠিক বিপরীত — canary-বিহীন একটা region-এ একই কৌশল সম্পূর্ণ
নীরবে, কোনো বাধা ছাড়াই সফল হয়েছে।
canary বাফার আর saved-rbp/return-address-এর মাঝে বসানো থাকায়, আগের লেসনের ২৪-byte আক্রমণ payload return address ছোঁয়ার আগেই canary corrupt করে ফেলে আর ret চালানোর আগেই abort ঘটায়; নতুন 32-byte layout-এ পৌঁছানো গেলেও secret canary না জানলে সেই abort এড়ানো যায় না; আর একটা canary-বিহীন heap-স্টাইল region-এ ঠিক একই overflow সম্পূর্ণ নীরবে সফল হয়, প্রমাণ করে canary শুধু stack frame-কেই রক্ষা করে।
নিজে বানান
DefenseInDepth — চারটা mitigation-কে একই hijack-path-এ গেট হিসেবে বসানো
- canary, aslr, nx, cfi — চারটা mitigation-কে independent boolean flag হিসেবে মডেল করুন
- একটা attacker profile dict বানান যা বলে attacker কী কী "জেনেছে" বা "অর্জন করেছে" — knows_canary, knows_base, uses_existing_code
- attempt_hijack()-এ চারটা গেট ধারাবাহিকভাবে চেক করুন — প্রতিটা গেট mitigation চালু থাকলে আর attacker সেটা না-জিতলে আক্রমণ থামিয়ে দেয়
- কমপক্ষে ৮টা scenario চালান — শূন্য mitigation থেকে চারটা mitigation পর্যন্ত, attacker-এর ক্ষমতা ধাপে ধাপে বাড়িয়ে
- দেখান শুধুমাত্র যে scenario-তে attacker চারটা গেটই একসাথে জিতেছে (canary leak + base leak + existing-code + CFI-compliant target), সেখানেই আক্রমণ সফল হয়েছে — অন্য কোনো সমন্বয়ে না
লক্ষ্য: আগের সেকশনগুলোতে প্রতিটা mitigation আলাদা করে দেখানো হয়েছে। এখানে চারটাকে একসাথে, একই hijack path-এ বসিয়ে দেখানো হবে — defense-in-depth আসলে সংখ্যায় কী করে তা প্রমাণ করার জন্য।
# defense_in_depth.py -- stack canary + ASLR + NX + CFI modeled as four
# independent gate functions in the SAME hijack path. Each gate can be
# switched on/off; an attacker profile can independently defeat zero or
# more gates (a leak beats ASLR, a gadget chain beats NX, a CFI-compliant
# chain beats CFI). The point: only an attacker who defeats ALL enabled
# gates simultaneously ever reaches "ret" with a hijacked target.
class Blocked(Exception):
pass
VALID_CALL_TARGETS = {0x0000555555555169, 0x0000555555555200} # legit function entries
def attempt_hijack(mitigations, attacker, target):
"""mitigations: dict of 4 bools. attacker: dict describing what the
attacker has defeated/obtained. Returns the stage that blocked the
attack, or 'SUCCESS'."""
# gate 1: stack canary -- blocks unless attacker separately leaked it
if mitigations["canary"] and not attacker["knows_canary"]:
return "BLOCKED at canary check (mismatch -> abort before ret)"
# gate 2: ASLR -- the hardcoded target is only correct if attacker knows the real base
if mitigations["aslr"] and not attacker["knows_base"]:
return "BLOCKED at ASLR (jump target lands in unmapped/wrong page)"
# gate 3: NX -- injected shellcode cannot execute; only pre-existing code (ROP) can
if mitigations["nx"] and not attacker["uses_existing_code"]:
return "BLOCKED at NX (stack/heap page marked non-executable, injected shellcode can't run)"
# gate 4: CFI -- indirect jump target must be a registered valid entry point
if mitigations["cfi"] and target not in VALID_CALL_TARGETS:
return "BLOCKED at CFI (jump target is not a valid registered entry point)"
return "SUCCESS -- attacker-controlled jump executes"
ATTACKER_TARGET = 0x0000555555de0d0d # a raw shellcode/gadget address, NOT a valid entry
CFI_COMPLIANT_TARGET = 0x0000555555555200 # a gadget chain landing exactly on a valid entry
scenarios = [
("no mitigations", dict(canary=False, aslr=False, nx=False, cfi=False),
dict(knows_canary=True, knows_base=True, uses_existing_code=False), ATTACKER_TARGET),
("+ canary", dict(canary=True, aslr=False, nx=False, cfi=False),
dict(knows_canary=False, knows_base=True, uses_existing_code=False), ATTACKER_TARGET),
("+ canary + ASLR", dict(canary=True, aslr=True, nx=False, cfi=False),
dict(knows_canary=False, knows_base=False, uses_existing_code=False), ATTACKER_TARGET),
("+ canary + ASLR (leaked base)", dict(canary=True, aslr=True, nx=False, cfi=False),
dict(knows_canary=False, knows_base=True, uses_existing_code=False), ATTACKER_TARGET),
("all four, attacker has ONLY a leak", dict(canary=True, aslr=True, nx=True, cfi=True),
dict(knows_canary=False, knows_base=True, uses_existing_code=False), ATTACKER_TARGET),
("all four, leak + ROP (raw gadget addr)", dict(canary=True, aslr=True, nx=True, cfi=True),
dict(knows_canary=False, knows_base=True, uses_existing_code=True), ATTACKER_TARGET),
("all four, leak + CFI-compliant ROP chain", dict(canary=True, aslr=True, nx=True, cfi=True),
dict(knows_canary=False, knows_base=True, uses_existing_code=True), CFI_COMPLIANT_TARGET),
("all four, leak + canary leak + CFI-compliant ROP", dict(canary=True, aslr=True, nx=True, cfi=True),
dict(knows_canary=True, knows_base=True, uses_existing_code=True), CFI_COMPLIANT_TARGET),
]
for name, mitig, attacker, target in scenarios:
on = "+".join(k for k, v in mitig.items() if v) or "none"
result = attempt_hijack(mitig, attacker, target)
print(f"[{name}]")
print(f" mitigations on = {on}")
print(f" attacker has = {', '.join(k for k,v in attacker.items() if v) or 'nothing extra'}")
print(f" -> {result}")
print()প্রকৃত চালিয়ে পাওয়া output (deterministic — হুবহু এটাই আসবে):
[no mitigations]
mitigations on = none
attacker has = knows_canary, knows_base
-> SUCCESS -- attacker-controlled jump executes
[+ canary]
mitigations on = canary
attacker has = knows_base
-> BLOCKED at canary check (mismatch -> abort before ret)
[+ canary + ASLR]
mitigations on = canary+aslr
attacker has = nothing extra
-> BLOCKED at canary check (mismatch -> abort before ret)
[+ canary + ASLR (leaked base)]
mitigations on = canary+aslr
attacker has = knows_base
-> BLOCKED at canary check (mismatch -> abort before ret)
[all four, attacker has ONLY a leak]
mitigations on = canary+aslr+nx+cfi
attacker has = knows_base
-> BLOCKED at canary check (mismatch -> abort before ret)
[all four, leak + ROP (raw gadget addr)]
mitigations on = canary+aslr+nx+cfi
attacker has = knows_base, uses_existing_code
-> BLOCKED at canary check (mismatch -> abort before ret)
[all four, leak + CFI-compliant ROP chain]
mitigations on = canary+aslr+nx+cfi
attacker has = knows_base, uses_existing_code
-> BLOCKED at canary check (mismatch -> abort before ret)
[all four, leak + canary leak + CFI-compliant ROP]
mitigations on = canary+aslr+nx+cfi
attacker has = knows_canary, knows_base, uses_existing_code
-> SUCCESS -- attacker-controlled jump executesলক্ষ্য করুন — আটটা scenario-র মধ্যে সাতটাই ব্লকড হয়েছে, প্রতিটাই সবচেয়ে প্রথম না-জেতা গেটে। শেষ scenario-তেই শুধু আক্রমণ সফল হয়েছে, কারণ সেখানেই একমাত্র attacker চারটা independent জিনিস একসাথে অর্জন করেছে: canary leak, ASLR base leak, existing-code (ROP) execution, আর একটা CFI-compliant টার্গেট। বাস্তব জগতে এই চারটা একসাথে পাওয়া একটামাত্র বাগ থেকে প্রায় অসম্ভব — সাধারণত প্রতিটার জন্য আলাদা, স্বাধীন একটা bug বা কৌশল লাগে। এটাই defense-in-depth-এর সংখ্যাগত যুক্তি: প্রতিটা নতুন স্তর আক্রমণের খরচ গুণিতকভাবে বাড়িয়ে দেয়, একটা একটা করে যোগ হয় না।
নিজে বাড়ান:
attempt_hijack-এ একটা পঞ্চম গেট যোগ করুন —shadow_stack, যেটা backward-edge CFI-কে আলাদাভাবে মডেল করে (return address স্ট্যাক-টপে বদলে গেলেও shadow copy-র সাথে না মিললে ব্লক)। এটাcfiগেট থেকে আলাদা কেন হতে পারে তা যুক্তি দিন- একটা
probabilityভার্সন লিখুন — প্রতিটা গেট-জয়ের একটা সম্ভাবনা (যেমন ASLR leak পাওয়ার সম্ভাবনা ৫%, canary leak ২%) ধরে নিয়ে পুরো chain সফল হওয়ার যৌথ সম্ভাবনা (independent event হলে গুণফল) হিসাব করুন — defense-in-depth কেন “যোগ” না “গুণ” তা সংখ্যায় দেখান scenarios-এ এমন একটা কেস যোগ করুন যেখানেnx=Falseকিন্তু বাকি তিনটা চালু — বাস্তবে কোন পরিস্থিতিতে NX বন্ধ থাকতে পারে (যেমন JIT compiler-ওয়ালা প্রোগ্রাম, যাদের runtime-এ কোড generate করতে হয়) আর কেন সেখানে আলাদা mitigation (W^X toggling) লাগে তা নোট করুন
বাস্তব সিস্টেমে
বাস্তব জগতে — চারটাই আজ ডিফল্ট, কিন্তু ভিন্ন পরিপক্বতায়
Stack canary — সবচেয়ে পুরনো, সবচেয়ে সস্তা, প্রায় সার্বজনীন ডিফল্ট।
GCC আর Clang-এ -fstack-protector (শুধু বড় local array-ওয়ালা function-এ
canary বসায়), -fstack-protector-strong (আরও আক্রমণাত্মক — array,
address-taken local variable, ইত্যাদি থাকলেই বসায়), আর
-fstack-protector-all (প্রতিটা function-এ, performance খরচে) — আজকের
প্রায় প্রতিটা distro-র ডিফল্ট কম্পাইলার-ফ্ল্যাগে -fstack-protector-strong
সক্রিয়। glibc-তে চেক-ব্যর্থ হলে __stack_chk_fail() কল হয়, যা সরাসরি
প্রোগ্রাম abort করে — আজকের guarded_epilogue-এর হুবহু বাস্তব সংস্করণ।
ASLR — kernel-স্তরের ফিচার, দুই দশক ধরে পরিপক্ব। Linux-এ
/proc/sys/kernel/randomize_va_space (মান ২ = সম্পূর্ণ randomization,
ডিফল্ট আধুনিক distro-তে) নিয়ন্ত্রণ করে; Windows-এ /DYNAMICBASE linker
ফ্ল্যাগ। তবে একটা গুরুত্বপূর্ণ শর্ত আছে — একটা executable আসলে
position-independent (PIE) হিসেবে কম্পাইল না হলে তার নিজের কোড
সেগমেন্ট randomize হয় না, শুধু stack/heap/library base হয়। আধুনিক
distro-গুলো তাই ডিফল্টভাবে সব executable PIE হিসেবে কম্পাইল করে।
NX bit — হার্ডওয়্যার-স্তরে এম্বেড, প্রায় সর্বজনীন। AMD ২০০৩-এ “Enhanced Virus Protection” নামে, Intel ২০০৪-এ “XD bit” (eXecute Disable) নামে হার্ডওয়্যার-স্তরে NX সমর্থন যোগ করে — আজ প্রায় প্রতিটা CPU-তে ডিফল্টভাবে সক্রিয়, OS শুধু page table entry-তে একটা bit সেট করে।
একটা সংক্ষিপ্ত timeline — অস্ত্র-প্রতিযোগিতার গতি। ১৯৯৬: return-address overwrite জনপ্রিয় হয় (Aleph One)। ১৯৯৮: StackGuard উত্তর দেয়। ~২০০০-২০০১: PaX ASLR + NX/PAGEEXEC নিয়ে আসে। ২০০৭: Shacham ROP দিয়ে NX-কে ঘুরে যান। ২০০৫-২০১৯: CFI-এর তাত্ত্বিক প্রস্তাব থেকে হার্ডওয়্যার-সমর্থিত বাস্তবায়ন পর্যন্ত ধীর যাত্রা। প্রতিটা রাউন্ডই আগেরটার একটা নির্দিষ্ট ফাঁক বন্ধ করেছে, আর প্রতিটা বন্ধ হওয়া দরজাই গবেষকদের একটা নতুন জানালা খুঁজতে বাধ্য করেছে — এটাই সিকিউরিটি মিটিগেশনের স্বাভাবিক, চলমান চরিত্র।
যে ভুলগুলো সবাই করে
“Stack canary, ASLR, NX, CFI — এই চারটা একসাথে থাকলে buffer overflow বাগ আর আসলে বিপজ্জনক না, কার্যত সমাধান হয়ে গেছে।”
আজকের build সেকশনের আটটা scenario-র মধ্যে শেষটাই প্রমাণ — একটা সমন্বয়
এখনও সফল হয়েছে। চারটা mitigation আক্রমণের খরচ বহুগুণ বাড়ায় (একাধিক
স্বাধীন bug/leak/gadget-chain একসাথে দরকার), কিন্তু bug নিজে — strcpy-এর
সেই unbounded copy — আজও ঠিক ততটাই সত্যি। উচ্চ-মূল্যের টার্গেটে (browser
sandbox escape, kernel privilege escalation) attacker-রা নিয়মিতভাবেই
এই পুরো চেইন — info leak + ROP + CFI bypass — একসাথে চেইন করে ফেলে,
ঠিক যে কারণে আজও প্রতি বছর নতুন CVE এই ধরনের চেইনড exploit হিসেবে প্রকাশিত
হয়। Mitigation attack কঠিন করে, bug মুছে ফেলে না।
“ASLR মানে প্রতিটা নতুন request বা connection-এ ঠিকানা আবার এলোমেলো হয়ে যায় — তাই একটা লম্বা-চলা সার্ভার প্রসেসেও প্রতিটা attempt নতুন randomization পায়।”
বাস্তবে ASLR randomize হয় প্রসেস চালু হওয়ার সময় (execve কল), তার
পরে না। একটা লম্বা-চলা সার্ভার প্রসেস — যেটা একবার চালু হয়ে হাজার হাজার
request serve করে — তার randomized base ঠিক ততক্ষণ একই থাকে যতক্ষণ
প্রসেসটা বেঁচে থাকে। যদি সেই প্রসেস প্রতিটা connection-এর জন্য
fork() করে (কিন্তু নতুন exec() না করে — যা অনেক নেটওয়ার্ক সার্ভিসের
স্বাভাবিক প্যাটার্ন), প্রতিটা child process parent-এর ইতিমধ্যে-randomize
হওয়া একই base উত্তরাধিকার সূত্রে পায় — কারণ fork() মেমরি-লেআউট কপি
করে, নতুন করে randomize করে না। ফলে একটা attacker একই randomized base-এর
বিপরীতে বারবার guess করার সুযোগ পেতে পারে, প্রতিটা নতুন connection নতুন
এন্ট্রপি আনে না — এটাই ASLR-নির্ভর সার্ভিস ডিজাইনে একটা পরিচিত সতর্কতা।
“একটা ছোট information-leak bug (যেমন একটা uninitialized memory read যা কিছু 'অপ্রাসঙ্গিক' bytes ফাঁস করে) নিজে থেকে exploitable না, তাই এটা নিয়ে জরুরি ভিত্তিতে patch করার দরকার নেই।”
Example সেকশনের entropy-গণনা সরাসরি এর বিরুদ্ধে সংখ্যাগত প্রমাণ —
bits=0 সারিতে দেখা গেছে একটা leak ASLR-এর পুরো protection-কে (৮ বিট
হোক বা ২৮ বিট) এক ধাক্কায় শূন্যে নামিয়ে দেয়। একটা “নিরীহ” leak প্রায়ই
ঠিক সেই একটা তথ্য ফাঁস করে যা attacker-এর দরকার — একটা stack address,
একটা heap pointer, একটা library base। আধুনিক security triage-এ তাই
info-leak bug-কে RCE (remote code execution) bug-এর সমান গুরুত্ব
দেওয়া হয়, কারণ প্রায়ই leak-ই RCE-চেইনের প্রথম, বাধ্যতামূলক ধাপ।
“CFI চালু থাকলে ROP/JOP-জাতীয় code-reuse আক্রমণ সম্পূর্ণ অসম্ভব হয়ে যায় — এটা একটা চূড়ান্ত, নিখুঁত সমাধান।”
Realworld সেকশনে দেখা গেছে — CET, CFG, BTI, PAC — এগুলোর প্রায় সবই
coarse-grained CFI: একটা indirect call-কে “যেকোনো ফাংশনের এন্ট্রি-পয়েন্টে”
যেতে দেয়, “শুধু সঠিক নির্দিষ্ট ফাংশনে” না, কারণ প্রতিটা call-site-এর জন্য
নিখুঁত fine-grained তালিকা রাখা performance-এ অনেক খরচ করে। এই শিথিলতাই
Jump-Oriented Programming (JOP) আর Counterfeit Object-Oriented
Programming (COOP)-এর মতো কৌশলের সুযোগ তৈরি করে — attacker gadget
বাছাই করেন যেগুলো বৈধ এন্ট্রি-পয়েন্টেও বসে, তাই CFI চেক পাস করেও
আক্রমণ চালিয়ে যাওয়া সম্ভব হয় (build সেকশনের শেষ scenario ঠিক এই ধারণাই
ব্যবহার করেছে — CFI_COMPLIANT_TARGET)। CFI আক্রমণের সুযোগ ব্যাপকভাবে
সংকুচিত করে, কিন্তু “বাতিল” শব্দটা এখনও অতিরিক্ত আশাবাদী।
বুঝেছেন কি না দেখুন
1নতুন canary-সহ frame layout-এ চারটা অঞ্চল ঠিক কোন ক্রমে বসে (নিম্ন থেকে
উচ্চ ঠিকানার দিকে), আর canary-র অবস্থান ঠিক কোন দুইটা অঞ্চলের মাঝে?
স্মরণ
নিম্ন থেকে উচ্চ ঠিকানার দিকে: local buffer, canary, saved rbp, return address। Canary বসে buffer আর saved-rbp-এর মাঝে — অর্থাৎ buffer-এর ঠিক পরে, saved-rbp/return-address-এর ঠিক আগে। এই অবস্থানের কারণেই একটা রৈখিক overflow return address-এ পৌঁছানোর আগে বাধ্যতামূলকভাবে canary-কে প্রথমে ছুঁতে হয়।
2Experiment-এর scenario ৩-এ return address সত্যিই সম্পূর্ণ ওভাররাইট হয়ে
ATTACKER_TARGET-এর সমান হয়ে গিয়েছিল, তবু আক্রমণ ব্যর্থ হলো। ঠিক কেন —
canary কীভাবে একটা “সফল” ওভাররাইটকেও অকেজো করে দিতে পারে?
যুক্তি
ATTACKER_TARGET-এর সমান হয়ে গিয়েছিল, তবু আক্রমণ ব্যর্থ হলো। ঠিক কেন —
canary কীভাবে একটা “সফল” ওভাররাইটকেও অকেজো করে দিতে পারে?Canary-চেক ret execute হওয়ার আগে ঘটে, epilogue-এর একটা আলাদা,
আগের ধাপ হিসেবে। Return address সম্পূর্ণ ওভাররাইট হয়ে গেলেও, সেই মান
কখনো ব্যবহারই হয় না যদি canary-চেক তার আগেই ব্যর্থ হয় — প্রোগ্রাম
RuntimeError/abort()-এ থেমে যায়, ret instruction পর্যন্ত execution
পৌঁছায়ই না। তাই “return address ওভাররাইট হয়েছে কিনা” আর “আক্রমণ সফল
হয়েছে কিনা” — এই দুইটা আলাদা প্রশ্ন, canary ঠিক এই দুইটার মাঝখানে
দাঁড়িয়ে থাকে।
3Example সেকশনের entropy-হিসাব অনুযায়ী, ৬৪-বিট Linux-এ ২৮ বিট এন্ট্রপি
থাকলে, সেকেন্ডে ১০০ চেষ্টার একজন remote attacker আর সেকেন্ডে ১০ লক্ষ
চেষ্টার একজন local attacker-এর মধ্যে ৫০% সফলতার প্রত্যাশিত সময়ে ঠিক
কত গুণ পার্থক্য হয়, আর কেন এই পার্থক্যটা ASLR-এর “মূল্য” মূল্যায়নে
গুরুত্বপূর্ণ?
প্রয়োগ
আউটপুট অনুযায়ী remote (১০০/সেকেন্ড)-এ লাগে প্রায় ৫১৬.৮ ঘণ্টা, local (১০ লক্ষ/সেকেন্ড)-এ লাগে প্রায় ৩.১ মিনিট — অর্থাৎ প্রায় ১০,০০০ গুণ (ঠিক attempts-per-second-এর অনুপাত, ১০,০০০) পার্থক্য। যেহেতু সময়টা সরাসরি আক্রমণ-হারের ব্যস্তানুপাতিক, ASLR-এর প্রকৃত সুরক্ষা attacker কোন চ্যানেল দিয়ে guess করছে তার ওপর ভীষণভাবে নির্ভরশীল — একই এন্ট্রপি সংখ্যা একজন remote attacker-কে কার্যত থামিয়ে দিতে পারে, অথচ একজন local বা দ্রুত-retry-সক্ষম attacker-এর কাছে কয়েক মিনিটের বাধা মাত্র।
4NX bit চালু থাকা একটা সিস্টেমে ROP আক্রমণ কেন প্রতিটা ret-এ NX বাধার
সম্মুখীন হয় না — যদিও প্রতিটা gadget আসলে attacker-নিয়ন্ত্রিত ক্রমে,
attacker-এর ইচ্ছামতো execute হচ্ছে?
যুক্তি
ret-এ NX বাধার
সম্মুখীন হয় না — যদিও প্রতিটা gadget আসলে attacker-নিয়ন্ত্রিত ক্রমে,
attacker-এর ইচ্ছামতো execute হচ্ছে?NX bit শুধু একটা মেমরি-পাতা executable কিনা তা চেক করে — কোন কোড
execute হচ্ছে, কে সেটা নির্ধারণ করেছে, বা কেন — এসব প্রশ্ন NX bit-এর
সংজ্ঞায় নেই। ROP-এর প্রতিটা gadget বাস্তবেই .text/library সেগমেন্টে
বসে থাকা, বৈধভাবে executable-চিহ্নিত কোড — শুধু attacker সেগুলোর
ক্রম ঠিক করেছে, কোনো নতুন byte data-থেকে-code হিসেবে execute হচ্ছে
না। তাই প্রতিটা ret-ই একটা executable পাতার দিকে জাম্প করছে, NX bit-এর
চোখে সম্পূর্ণ বৈধ — এটাই ROP-এর মূল কৌশল: নতুন কোড আনা লাগে না, শুধু
পুরনো কোডের ব্যবহারের ক্রম বদলাতে হয়।
5Build সেকশনের আটটা scenario-র মধ্যে “all four, leak + ROP (raw gadget
addr)” scenario-টা এখনও canary-তেই ব্লক হয়েছে, যদিও attacker ইতিমধ্যে
ASLR base leak করেছে আর existing-code (ROP)-ও ব্যবহার করছে। কেন এই
দুইটা অর্জন থাকা সত্ত্বেও canary গেট এখনও পার হওয়া গেল না?
প্রয়োগ
attempt_hijack-এ গেটগুলো ধারাবাহিকভাবে চেক হয়, canary সবার আগে —
mitigations["canary"] and not attacker["knows_canary"] সত্যি হলেই
ফাংশন সেখানেই return করে, পরের গেটগুলো (aslr, nx, cfi) আর চেকই হয়
না। এই scenario-তে attacker-প্রোফাইলে knows_canary=False — ASLR
leak আর ROP ক্ষমতা থাকলেও canary secret আলাদা একটা bug/leak ছাড়া জানা
যায় না, তাই প্রথম গেটেই আক্রমণ থেমে যায়। এটাই defense-in-depth-এর মূল
বিন্দু: গেটগুলো independent, একটা জেতা মানেই পরেরটা জেতা না।
6একটা নতুন network-facing C++ সার্ভিস ডিজাইন করছেন যা একটা legacy,
performance-critical parsing library ব্যবহার করে (পুরো rewrite অসম্ভব,
এই sprint-এ)। বাজেট সীমিত — সব কয়টা mitigation “সর্বোচ্চ সেটিং”-এ চালু
করলে measurable performance regression আসবে। কীভাবে অগ্রাধিকার ঠিক
করবেন, আর কোন mitigation-টা “protect always” আর কোনটা “profile first”
বিভাগে রাখবেন?
ডিজাইন
Stack canary, ASLR, আর NX — এই তিনটার performance overhead এতটাই কম
(এক অঙ্কের শতাংশ, বেশিরভাগ ক্ষেত্রে পরিমাপযোগ্যও না) যে এগুলো “protect
always” বিভাগে পড়া উচিত — বন্ধ রাখার কোনো যুক্তিসঙ্গত কারণ নেই এই
স্কেলে, -fstack-protector-strong + PIE + NX ডিফল্ট কম্পাইলার/লিংকার
ফ্ল্যাগেই আসে, প্রায় বিনামূল্যে। CFI (বিশেষত fine-grained, non-hardware-
সমর্থিত সফটওয়্যার বাস্তবায়ন) তুলনামূলক ব্যয়বহুল আর জটিল — এখানে
“profile first” পদ্ধতি: হার্ডওয়্যার-সমর্থিত রূপ (Intel CET, ARM BTI/PAC)
আগে যাচাই করা উচিত, কারণ সেগুলোর overhead সাধারণত সফটওয়্যার-শুধু
বাস্তবায়নের চেয়ে অনেক কম। parsing library-টাই যেহেতু সবচেয়ে বেশি
untrusted, network-facing input হ্যান্ডল করে, সেটাকেই প্রথমে সবচেয়ে
কড়া CFI/sandbox-এর আওতায় আনা উচিত (attack surface অনুযায়ী অগ্রাধিকার
— ঠিক আগের লেসনের ডিজাইন-প্রশ্নের মতোই যুক্তি), বাকি “trusted” internal
কোডে হালকা সেটিং দিয়ে শুরু করে প্রয়োজনে বাড়ানো যেতে পারে।
এরপর কী
এরপর কী
আজ দেখলাম চারটা mitigation — stack canary, ASLR, NX bit, আর CFI — প্রতিটাই আগের লেসনের একটা নির্দিষ্ট ধাপ বন্ধ করে দিয়েছে, আর প্রতিটার একটা নির্দিষ্ট, নথিভুক্ত ফাঁক রয়ে গেছে। Canary heap-কে রক্ষা করে না, ASLR একটা leak-এই ভেঙে পড়ে, NX Return-Oriented Programming-কে থামাতে পারে না, আর CFI নিজেও পুরোপুরি নিখুঁত না। Build সেকশনের সিমুলেশন প্রমাণ করেছে — চারটা একসাথে স্ট্যাক করলে attacker-কে একইসাথে একাধিক স্বাধীন bypass চেইন করতে বাধ্য হতে হয়, যা আক্রমণের খরচ বহুগুণ বাড়িয়ে দেয় — কিন্তু শূন্যে নামায় না।
তবে এই পুরো লেসন, আর তার আগের লেসনও, একটা নির্দিষ্ট শর্তের ওপর দাঁড়িয়ে ছিল: আক্রমণটা stack-based, return-address-hijacking ধরনের। Canary return address-এর সামনে বসে, ASLR সেই ঠিকানা অনুমান কঠিন করে, NX সেই ঠিকানায় injection ঠেকায়, CFI সেই জাম্পের টার্গেট যাচাই করে — চারটাই এক অর্থে “return address”-কেন্দ্রিক প্রতিরক্ষা।
কিন্তু আজকের browser আর kernel-এ সবচেয়ে বেশি প্রকাশিত, সবচেয়ে বেশি exploited vulnerability class পুরোপুরি অন্য জায়গায় বাস করে — heap-এ। সেখানে “return address” নামের কোনো একক, predictable টার্গেট নেই; সমস্যাটা memory-র জীবনকাল নিয়ে — কোনো একটা মেমরি অঞ্চল কতক্ষণ “বৈধ,” কে তার মালিক, আর সেই মালিকানার হিসাব কীভাবে ভেঙে পড়ে। পরের লেসনে দেখব ঠিক সেই জায়গাটা — use-after-free আর double-free — যেখানে আজকের চারটা mitigation-এর একটাও সরাসরি প্রযোজ্য না, কারণ বাগটাই একদম ভিন্ন প্রকৃতির।
আরও পড়ুন
- StackGuard: Automatic Adaptive Detection and Prevention of Buffer-Overflow Attacks — Crispin Cowan et al. — USENIX Security Symposium, 1998 · Stack canary কৌশলের প্রতিষ্ঠাতা পেপার — return address-এর সামনে একটা 'canary word' বসিয়ে overflow detect করার আইডিয়া প্রথম প্রকাশিত হয় এখানেই
- PaX ASLR — Address Space Layout Randomization — The PaX Team · লিনাক্সে ASLR-এর মূল ডিজাইন দলিল — mmap/heap/stack base randomize করার কৌশল আর প্রতিটা অঞ্চলের entropy বিটের হিসাব এখানেই প্রথম আনুষ্ঠানিকভাবে বর্ণিত
- PaX NOEXEC — Non-Executable Pages Design & Implementation — The PaX Team · NX bit/PAGEEXEC-এর ডিজাইন দলিল — কীভাবে page-level non-executable enforcement দিয়ে data অঞ্চল থেকে code execute হওয়া বন্ধ করা যায়
- The Geometry of Innocent Flesh on the Bone: Return-into-libc without Function Calls (on the x86) — Hovav Shacham — ACM CCS 2007 · Return-Oriented Programming-এর প্রতিষ্ঠাতা পেপার — দেখায় x86 code-এর যেকোনো টুকরোয় লুকিয়ে থাকা ছোট 'gadget'-গুলো জুড়ে NX bit-কে সম্পূর্ণ বাইপাস করে Turing-complete আক্রমণ বানানো সম্ভব