Foundationপ্রথম নীতি থেকে
LEVEL 10লেসন ১৮/২০রিসার্চ-লেভেল১ ঘণ্টা ৫ মিনিট

Side-Channel Attack, Spectre, Meltdown — যখন CPU নিজেই গোপন কথা ফাঁস করে

Side-Channel Attacks: Spectre and Meltdown

একটা প্রোগ্রাম গাণিতিকভাবে নিরাপদ হয়েও শুধু কতক্ষণ সময় নিচ্ছে বা কোন cache line স্পর্শ করছে তা দিয়েই গোপন তথ্য ফাঁস করতে পারে — timing আর cache-timing side channel থেকে শুরু করে দেখা যায় speculative execution-এর architectural rollback কীভাবে microarchitectural cache state আড়াল করে না, আর ঠিক এই ফাঁকটাই Meltdown আর Spectre ব্যবহার করে, প্রথমটা KPTI দিয়ে বন্ধ করা গেলেও দ্বিতীয়টা branch-prediction মেকানিজমের মৌলিক প্রকৃতির কারণে আজও সম্পূর্ণ সমাধানহীন।

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

  • Side-channel attack ঠিক কী, আর কেন এটা 'গণিত ভাঙা' নয় বরং প্রোগ্রামের চলার আচরণ (timing, cache state) থেকে গোপন তথ্য বের করা — এই মৌলিক পার্থক্যটা নিজের ভাষায় ব্যাখ্যা করতে পারবেন
  • একটা non-constant-time string comparison থেকে ঠিক কীভাবে byte-by-byte secret পুনরুদ্ধার সম্ভব তা নিজে হাতে সময়-পরিমাপ করে প্রমাণ করে দেখাতে পারবেন, আর কেন constant-time comparison এই leak বন্ধ করে তা যুক্তি দিতে পারবেন
  • Flush+Reload-ধাঁচের cache-timing covert channel কীভাবে কাজ করে — cache hit আর miss-এর latency-ব্যবধানকেই কীভাবে একটা যোগাযোগ-চ্যানেলে রূপান্তর করা যায় — তা সিমুলেশন দিয়ে দেখাতে পারবেন
  • Speculative execution কেন architectural state রোলব্যাক করেও microarchitectural (cache) state রোলব্যাক করে না, আর এই ফাঁকটাই কীভাবে Meltdown (permission-check bypass) আর Spectre (branch-predictor mistraining)-এর ভিত্তি গঠন করে তা ব্যাখ্যা করতে পারবেন
  • কেন KPTI Meltdown সম্পূর্ণ ঠেকাতে পারলেও Spectre-এর জন্য একটা সম্পূর্ণ, চূড়ান্ত সমাধান আজও নেই — branch prediction মেকানিজমের মৌলিক প্রকৃতির সাথে এই অসমাধানযোগ্যতার সম্পর্ক যুক্তি দিয়ে ব্যাখ্যা করতে পারবেন

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

আগে এটা বুঝি

আজ পর্যন্ত এই মডিউলের প্রতিটা আক্রমণের একটা সাধারণ বৈশিষ্ট্য ছিল — attacker কোনো না কোনোভাবে প্রোগ্রামের input বা memory-র সাথে সরাসরি interact করছিল: একটা buffer-এ বেশি বাইট ঢুকিয়ে দেওয়া, একটা freed pointer আবার ব্যবহার করা, একটা crafted request পাঠানো। আজকের আক্রমণ-শ্রেণিটা সম্পূর্ণ ভিন্ন নিয়মে চলে — attacker প্রোগ্রামের input-output-এর সাথে একটা বাইটও ভুল করে না, কোনো memory-safety bug কাজে লাগায় না, এমনকি প্রোগ্রামের কোডে কোনো bug নাও থাকতে পারে। তবু গোপন তথ্য ফাঁস হয়ে যায় — কারণ attacker প্রোগ্রামটা কী উত্তর দিচ্ছে সেটা দেখছে না, দেখছে প্রোগ্রামটা কীভাবে চলছে: কত সময় নিচ্ছে, কোন cache line স্পর্শ করছে, কতটা বিদ্যুৎ খরচ করছে।

এটাই side-channel attack — মূল, উদ্দেশ্যমূলক (“channel”) যোগাযোগ-পথ (input দাও, output পাও) বাদ দিয়ে একটা পার্শ্ব-চ্যানেল ব্যবহার করা, যেটা সিস্টেম ডিজাইনার কখনো তথ্য বহনের জন্য বানানই করেননি — এটা শুধু বাস্তব হার্ডওয়্যারে গণনা করার একটা অনিবার্য পার্শ্ব-ফলাফল (time লাগে, বিদ্যুৎ খরচ হয়, cache state বদলায়)। আজকের লেসন একটা নির্দিষ্ট, সুনির্দিষ্ট চড়াই বেয়ে উঠবে — প্রতিটা ধাপ আগেরটার ওপর দাঁড়িয়ে:

(১) সবচেয়ে সরল সাইড-চ্যানেল — একটা software timing attack, নিজে হাতে চালিয়ে, real time মেপে, একটা secret সত্যিই byte-by-byte পুনরুদ্ধার করে দেখানো হবে। (২) সেই একই নীতি hardware স্তরে — cache hit বনাম miss-এর latency-ব্যবধানকেই কীভাবে একটা যোগাযোগ-চ্যানেলে রূপান্তর করা যায় (Flush+Reload), একটা সিমুলেশনে। (৩) computer-architecture module-এর branch prediction আর out-of-order execution লেসনের সাথে সংযোগ — CPU speculatively কাজ চালিয়ে যায়, ভুল প্রমাণিত হলে ফলাফল বাতিল করে, কিন্তু ঠিক কতটা বাতিল করে? (৪) Meltdown — একটা permission check শেষ হওয়ার আগেই speculative read kernel memory পড়ে ফেলে। (৫) Spectre — branch predictor-কেই ভুল শেখানো, process boundary পার হয়ে।

মূল ধারণা

Side-channel attack — সংজ্ঞা, আর কেন এটা “hacking” থেকে আলাদা

একটা প্রোগ্রাম বা প্রোটোকলের মূল চ্যানেল হলো তার ঘোষিত interface — input দাও, output পাও, ডকুমেন্টেশনে যা লেখা আছে ঠিক তাই। Side channel হলো সেই একই সিস্টেমের যেকোনো পরিমাপযোগ্য পার্শ্ব-প্রভাব যেটা secret data-র সাথে statistically correlated, অথচ কোনো ডিজাইনার সেটাকে “আউটপুট” হিসেবে ভাবেননি:

Side-channel ধরনকী মাপা হয়Secret-এর সাথে সম্পর্ক
TimingOperation শেষ হতে কতক্ষণ লাগলEarly-exit loop, cache hit/miss, branch prediction — সবকিছুই secret-নির্ভর সময় নেয়
Cacheকোন cache line “গরম” (hot) না “ঠান্ডা” (cold)Secret-নির্ভর memory access cache-এ একটা স্থায়ী চিহ্ন রেখে যায়
Powerচিপ কত বিদ্যুৎ টানছে, সময়ের সাথেভিন্ন bit value/instruction ভিন্ন পরিমাণ transistor সুইচ করায়
Electromagnetic (EM)চিপ থেকে বিকিরিত EM signalPower-এর মতোই, কিন্তু কন্টাক্ট ছাড়াই দূর থেকে পরিমাপযোগ্য
Acousticচিপ/ফ্যান/capacitor-এর শব্দচরম-precision measurement-এ কিছু cryptographic operation-এ ব্যবহৃত হয়েছে

আজকের ফোকাস প্রথম দুইটা সারি — timing আর cache — কারণ এই দুইটাই সরাসরি software দিয়ে পরিমাপযোগ্য (কোনো বিশেষ hardware probe, oscilloscope, বা physical access লাগে না), আর ঠিক এই দুইটাই Meltdown/ Spectre-এর ভিত্তি।

Attacker-এর মডেল — কী লাগে, কী লাগে না

একটা timing/cache side-channel attack চালাতে attacker-এর দরকার শুধু এই দুইটা জিনিস: (১) target operation বারবার (বা অনেকবার, বিভিন্ন input-এ) ট্রিগার করার ক্ষমতা, আর (২) সেই operation-এর সময় বা তার একটা পরোক্ষ প্রতিফলন (যেমন cache state) মাপার ক্ষমতা। কোনো memory-corruption bug লাগে না, কোনো credential leak লাগে না — শুধু repetition আর measurement। এই কম প্রয়োজনীয়তাই সাইড-চ্যানেল আক্রমণকে এত ব্যাপকভাবে প্রযোজ্য করে তোলে, remote web server থেকে শুরু করে browser-এর ভেতরের JavaScript পর্যন্ত (realworld সেকশনে বিস্তারিত)।

ভেতরে কী ঘটছে

কেন সময় বদলায় — একটা naive comparison-এর ভেতরে

সবচেয়ে সরল timing side channel জন্ম নেয় একটা অতি-সাধারণ কোড-প্যাটার্ন থেকে: early-exit comparison। একটা secret token/password/API-key যাচাই করার সবচেয়ে স্বজ্ঞাত উপায় হলো byte-by-byte তুলনা করে, প্রথম অমিল পেলেই থেমে যাওয়া — ঠিক যেভাবে একজন মানুষ দুইটা শব্দ মেলাবে:

guess:  K  9  p  X
secret: K  9  p  Q
        ✓  ✓  ✓  ✗ ← এখানেই থেমে যায়, বাকি বাইট আর দেখেই না

এই “থেমে যাওয়া”-টাই সমস্যা। একটা guess-এর প্রথম 3টা বাইট মিললে comparison function-কে 4টা ধাপ চালাতে হয় (৩টা মিল + ১টা অমিল ধরার জন্য); প্রথম বাইটেই অমিল হলে মাত্র 1টা ধাপ। বেশি ধাপ মানে বেশি সময় — আর সেই সময়ের পার্থক্য, যদিও একটা মাত্র তুলনায় micro/nanosecond স্তরের, বহুবার মেপে গড় নিলে (বা সবচেয়ে কম সময়টা নিলে — কেন, নিচে) পরিসংখ্যানগতভাবে স্পষ্ট হয়ে ওঠে।

এই একই যুক্তি hardware স্তরে হুবহু প্রযোজ্য — শুধু “loop-এর ধাপ”-এর বদলে “cache hit বনাম miss”। computer-architecture/cache-organization লেসনে দেখা গেছে একটা L1 cache lookup-এর latency ~4 cycle-এর কাছাকাছি, আর computer-architecture/superscalar-and-out-of-order লেসনে দেখা গেছে একটা cache miss (DRAM পর্যন্ত যেতে) ~200 cycle পর্যন্ত লাগতে পারে। এই ৫০ গুণ ব্যবধানই পরের সেকশনের cache-timing covert channel-এর পুরো ভিত্তি — নিচের experiment-এর সিমুলেশনেও ঠিক এই দুইটা সংখ্যা (4 আর 200) ব্যবহার করা হয়েছে, কাকতালীয় না, ইচ্ছাকৃতভাবে সেই লেসনের বাস্তব latency figure-এর সাথে মিলিয়ে।

উদাহরণ

Flush+Reload — cache-কে একটা যোগাযোগ-চ্যানেলে রূপান্তর

Yarom ও Falkner-এর ২০১৪-এর কৌশল, যেটা আজও বেশিরভাগ cache-timing আক্রমণের ভিত্তি, তিনটা ধাপে সরল:

ধাপ ১ — FLUSH
  attacker সব সম্ভাব্য "probe line"-কে cache থেকে সরিয়ে দেয় (বা,
  আজকের সিমুলেশনে, সবকিছু 'cached' সেট থেকে বাদ দেয়) — শুরুর অবস্থা:
  সবকিছু ঠান্ডা (cold)।

ধাপ ২ — VICTIM চলে (attacker-এর নিয়ন্ত্রণের বাইরে)
  victim একটা secret-নির্ভর ঠিকানায় access করে — যেমন
  probe_array[secret_byte] — এই একটা মাত্র access একটা মাত্র cache
  line-কে গরম করে, ঠিক সেই লাইন যেটা secret_byte-এর মান নির্দেশ করে।

ধাপ ৩ — RELOAD
  attacker প্রতিটা সম্ভাব্য probe line-এ (secret যত রকম হতে পারে,
  ততগুলো লাইনে) একে একে access করে, প্রতিটার সময় মাপে — যেটা দ্রুত
  ফেরত আসে (HIT), সেটাই victim যে লাইন স্পর্শ করেছিল, আর তাই secret
  byte-এর মান প্রকাশ করে দেয়।
Flush+Reload-এর তিনটা ধাপ — victim-এর একটামাত্র access-ই attacker-এর কাছে একটা measurable ফলাফল রেখে যায়।

লক্ষ্য করুন — attacker কখনো victim-এর memory থেকে সরাসরি কিছু পড়েনি। সে শুধু নিজের measurement নিয়েছে (নিজের probe access-এর সময়) — আর সেই measurement-ই victim-এর secret-এর একটা bit-perfect প্রতিচ্ছবি। এটাই side channel-এর সংজ্ঞাগত বৈশিষ্ট্য: তথ্য প্রবাহিত হয় একটা অনিচ্ছাকৃত পথ দিয়ে, যেটা কোনো access-control বা encryption-এর আওতায় কখনো ছিলই না — কারণ ডিজাইনাররা কখনো ভাবেননি cache state নিজেই একটা readable “output”।

ছবিতে

Speculative execution — architectural rollback হয়, microarchitectural rollback হয় না

computer-architecture/branch-prediction আর computer-architecture/superscalar-and-out-of-order লেসনে দেখা গেছে — CPU branch-এর ফলাফল না জেনেই একটা predicted পথ ধরে এগিয়ে যায় (speculative execution), আর ভুল প্রমাণিত হলে সেই কাজ flush করে ফেলে। branch-prediction লেসনের misconception সেকশনে স্পষ্ট করেই বলা হয়েছিল: misprediction শুধু performance সমস্যা, correctness সমস্যা না, কারণ reorder buffer নিশ্চিত করে ভুল speculative কাজ কখনো architectural state-এ (register, memory-তে দৃশ্যমান মান) স্থায়ীভাবে প্রতিফলিত হয় না।

কিন্তু সেই গ্যারান্টির একটা সুনির্দিষ্ট সীমা আছে, যেটা ওই লেসনগুলো ইচ্ছাকৃতভাবে খোলা রেখেছিল — আজ সেটাই বন্ধ করার পালা।

একটা speculative load-এর সম্পূর্ণ জীবনচক্র — কোথায় 'rollback' থামে
  1. fetch + predictbranch predictor একটা পথ অনুমান করে (branch-prediction লেসনের 2-bit counter), CPU সেই পথ ধরে এগোয়
  2. speculative executeঅনুমানের ওপর ভিত্তি করে instruction চলে — একটা memory read, তারপর সেই মান দিয়ে আরেকটা array-access
  3. microarchitectural side-effect ঘটেসেই array-access একটা নির্দিষ্ট cache line গরম করে — এই মুহূর্তেই, resolve হওয়ার আগেই
  4. branch/check resolve হয়আসল ফলাফল জানা যায় — prediction সঠিক ছিল, নাকি ভুল (বা permission check fail করল)
  5. architectural rollback (যদি ভুল হয়)register, memory-তে দৃশ্যমান কোনো পরিবর্তন বাতিল হয় — program-এর দৃষ্টিতে যেন কিছুই ঘটেনি
  6. microarchitectural state অপরিবর্তিত থেকে যায়কিন্তু cache line এখনও গরম! rollback শুধু architectural state মুছেছে, cache কখনো "undo" হয় না — এখানেই leak

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

EXPERIMENT

Timing attack — একটা secret সত্যিই byte-by-byte পুনরুদ্ধার, real time মেপে

macOS / Linux / Windows (Python 3)· ১৫ মিনিট
# timing_attack.py -- recover a secret byte-by-byte from a non-constant-time comparison
import time, statistics

SECRET = b"K9pQ"   # short secret, kept short so the full scan finishes quickly

def slow_step():
    # stand-in for the small, real per-character cost a naive comparison loop pays
    # (in real C, this is closer to a single cheap branch+load per iteration; here
    # we make that per-iteration cost explicit and large enough to measure reliably
    # from Python, whose own interpreter overhead would otherwise swamp the signal)
    x = 0
    for _ in range(20000):
        x += 1
    return x

TERMINATOR = b"\x00"   # stand-in for a C-string's trailing NUL, checked like any other byte

def naive_compare(guess: bytes, secret: bytes) -> bool:
    """Early-exit comparison -- returns False the INSTANT a mismatched byte is
    found, exactly like a hand-written `for i in range(n): if a[i]!=b[i]: return False`
    (or an unguarded C memcmp substitute). A trailing TERMINATOR byte is compared
    too (just like a C string's NUL) -- this is what makes even the LAST secret
    byte leak a timing signal: a fully-correct guess pays one extra step (the
    terminator match) that a wrong-last-byte guess never reaches."""
    g_full, s_full = guess + TERMINATOR, secret + TERMINATOR
    if len(g_full) != len(s_full):
        return False
    for g, s in zip(g_full, s_full):
        slow_step()              # cost paid for EVERY byte checked so far
        if g != s:
            return False          # stop here -- no cost for remaining bytes
    return True

def time_compare(guess, secret, trials=25):
    times = []
    for _ in range(trials):
        t0 = time.perf_counter()
        naive_compare(guess, secret)
        t1 = time.perf_counter()
        times.append(t1 - t0)
    return min(times)   # min, not mean/median -- OS scheduling noise only ever ADDS
                         # delay, never subtracts it, so the minimum across many
                         # trials is the cleanest estimate of the true per-call cost

ALPHABET = [bytes([c]) for c in range(0x30, 0x7b)]   # printable ASCII guess space, excludes '?'
FILLER = b"?"

recovered = b""
print(f"real secret (attacker cannot see this) = {SECRET!r}\n")

for pos in range(len(SECRET)):
    best_byte, best_time = None, -1.0
    for b in ALPHABET:
        guess = recovered + b + FILLER * (len(SECRET) - pos - 1)
        t = time_compare(guess, SECRET, trials=25)
        if t > best_time:
            best_time, best_byte = t, b
    recovered += best_byte
    print(f"position {pos}: best guess byte = {best_byte!r} (min {best_time*1e6:8.2f} us) -> recovered so far = {recovered!r}")

print(f"\nrecovered secret = {recovered!r}")
print(f"matches real secret? {recovered == SECRET}")

# ---------- constant-time fix: cost independent of where the mismatch is ----------
def constant_time_compare(a: bytes, b: bytes) -> bool:
    if len(a) != len(b):
        return False
    result = 0
    for x, y in zip(a, b):
        slow_step()             # SAME cost paid for every byte, always, no early exit
        result |= x ^ y
    return result == 0

def time_constant(guess, secret, trials=25):
    times = []
    for _ in range(trials):
        t0 = time.perf_counter()
        constant_time_compare(guess, secret)
        t1 = time.perf_counter()
        times.append(t1 - t0)
    return min(times)

print("\n--- same attack methodology, now against constant_time_compare ---")
sample_guesses = [
    b"?" * len(SECRET),                          # all wrong
    SECRET[:1] + b"?" * (len(SECRET) - 1),        # first byte right
    SECRET[:2] + b"?" * (len(SECRET) - 2),        # first two bytes right
    SECRET,                                       # fully right
]
for g in sample_guesses:
    t = time_constant(g, SECRET, trials=25)
    print(f"guess={g!r:12} min time = {t*1e6:8.2f} us")

প্রকৃত চালিয়ে পাওয়া output (এই মেশিনে; absolute microsecond-সংখ্যা মেশিন আর load-ভেদে বদলাবে, কিন্তু প্যাটার্নটা — naive compare-এ matched-prefix বাড়ার সাথে সময় একটানা বাড়া, constant-time compare-এ সময় সমতল থাকা — যেকোনো মেশিনেই একইভাবে reproduce হবে):

real secret (attacker cannot see this) = b'K9pQ'

position 0: best guess byte = b'K' (min  1673.44 us) -> recovered so far = b'K'
position 1: best guess byte = b'9' (min  2569.35 us) -> recovered so far = b'K9'
position 2: best guess byte = b'p' (min  3344.76 us) -> recovered so far = b'K9p'
position 3: best guess byte = b'Q' (min  4094.07 us) -> recovered so far = b'K9pQ'

recovered secret = b'K9pQ'
matches real secret? True

--- same attack methodology, now against constant_time_compare ---
guess=b'????'      min time =  3410.71 us
guess=b'K???'      min time =  3434.44 us
guess=b'K9??'      min time =  3383.62 us
guess=b'K9pQ'      min time =  3461.93 us

সংখ্যাগুলো নিজেই গল্পটা বলছে। naive_compare-এ প্রতিটা সঠিক byte matched-এ সময় একটানা বাড়ছে — 1673 → 2569 → 3345 → 4094 microsecond, প্রতি ধাপে প্রায় ~৮০০ microsecond বৃদ্ধি, ঠিক slow_step()-এর একবার বেশি চলার সমান। Attacker এই বৃদ্ধিটাই খুঁজেছে — যে guess byte-এ সময় সবচেয়ে বেশি, সেটাই সঠিক — আর চারটা position-এই সঠিক byte বেছে K9pQ সম্পূর্ণ পুনরুদ্ধার করেছে, secret-এর একটা byte-ও কখনো সরাসরি না দেখে। constant_time_compare-এ ঠিক একই আক্রমণ-কৌশল প্রয়োগ করা হয়েছে (চারটা ভিন্ন correctness-level-এর guess), কিন্তু সময় প্রতিবারই ~3400-3460 microsecond-এর মধ্যে — কোনো ক্রমবর্ধমান প্যাটার্ন নেই, কারণ প্রতিটা byte-ই, মিলুক বা না মিলুক, ঠিক একই slow_step() সংখ্যা চালায়। এটাই constant-time comparison-এর পুরো দাবি: matched length সম্পর্কে timing কিছুই বলে না।

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

একটা early-exit string comparison-এর execution time matched-prefix length-এর সাথে পরিমাপযোগ্যভাবে বাড়ে — আর শুধু সেই সময়ের পার্থক্য মেপেই, secret-এর একটা byte-ও সরাসরি না দেখে, পুরো secret পুনরুদ্ধার করা যায়; constant-time comparison-এ ঠিক একই আক্রমণ-পদ্ধতি প্রয়োগ করলে timing সংকেত সম্পূর্ণ সমতল হয়ে যায়।

EXPERIMENT

Cache-timing covert channel — Flush+Reload-এর একটা সরলীকৃত সিমুলেশন

macOS / Linux / Windows (Python 3) — hardware প্রয়োজন নেই, সম্পূর্ণ সিমুলেটেড· ১০ মিনিট
# cache_covert_channel.py -- SIMPLIFIED, fully simulated Flush+Reload-style
# cache-timing covert channel. This is NOT measuring a real CPU cache -- it
# models the *mechanism* Yarom & Falkner formalized in 2014: touching an
# address loads its cache line; touching that SAME line again afterward is
# measurably faster than touching a line that was never loaded. We simulate
# 256 "probe lines" (one per possible secret-byte value 0..255) and show that
# a victim's single memory access leaves a timing fingerprint an attacker can
# read back later, with no direct access to the victim's memory at all.

CACHE_HIT_CYCLES = 4        # illustrative -- matches cache-organization.mdx's L1 latency (~4 cycle)
CACHE_MISS_CYCLES = 200     # illustrative -- matches superscalar-and-out-of-order.mdx's DRAM-miss figure (~200 cycle)
NUM_LINES = 256              # one probe line per possible byte value 0..255

class SimulatedCache:
    def __init__(self):
        self.cached = set()   # which of the probe lines currently hold data

    def flush_all(self):
        self.cached.clear()

    def access(self, line: int) -> int:
        """Returns the simulated cycle cost of touching `line`. Touching a
        line ALWAYS caches it afterward -- exactly like a real memory access."""
        hit = line in self.cached
        self.cached.add(line)
        return CACHE_HIT_CYCLES if hit else CACHE_MISS_CYCLES

def victim_touches_secret_line(cache, secret_byte, probe_base):
    """The only architecturally-visible action the victim takes: it reads
    probe_array[secret_byte]. This loads exactly ONE of the 256 lines --
    which one depends entirely on the secret byte's VALUE, not its identity."""
    cache.access(probe_base + secret_byte)

def attacker_recover_byte(cache, probe_base):
    """FLUSH+RELOAD, Yarom & Falkner 2014: (1) flush every probe line before
    the victim runs, (2) let the victim run (outside the attacker's control),
    (3) time an access to all 256 lines afterward -- the one that comes back
    fast (a hit) is the line the victim touched, which reveals the secret
    byte's value without ever reading the victim's memory directly."""
    timings = {}
    for candidate in range(NUM_LINES):
        cost = cache.access(probe_base + candidate)
        timings[candidate] = cost
    fastest = min(timings, key=timings.get)
    return fastest, timings

cache = SimulatedCache()
SECRET_BYTE = 0x41   # 'A' -- unknown to the attacker in a real attack
PROBE_BASE = 0

print(f"real secret byte (attacker cannot see this) = {SECRET_BYTE:#04x} ({chr(SECRET_BYTE)!r})\n")

print("--- round 1: flush all 256 lines, victim touches ONE, attacker probes all 256 ---")
cache.flush_all()
victim_touches_secret_line(cache, SECRET_BYTE, PROBE_BASE)
recovered, timings = attacker_recover_byte(cache, PROBE_BASE)
print(f"attacker recovered byte = {recovered:#04x} ({chr(recovered)!r})")
print(f"matches real secret?    = {recovered == SECRET_BYTE}")
print(f"timing on recovered line {recovered:#04x} = {timings[recovered]:3d} cycles (HIT)")
other = 0x00 if recovered != 0x00 else 0x01
print(f"timing on a random OTHER line {other:#04x} = {timings[other]:3d} cycles (MISS)")
n_hits = sum(1 for c in timings.values() if c == CACHE_HIT_CYCLES)
print(f"how many of the 256 probed lines came back as a HIT? {n_hits} (exactly the touched one)")

print("\n--- round 2: recover a whole multi-byte secret, one byte at a time ---")
SECRET_MESSAGE = b"CS"
recovered_bytes = bytearray()
for i, true_byte in enumerate(SECRET_MESSAGE):
    cache.flush_all()
    victim_touches_secret_line(cache, true_byte, PROBE_BASE)
    got, _ = attacker_recover_byte(cache, PROBE_BASE)
    recovered_bytes.append(got)
    print(f"byte {i}: true={true_byte:#04x} ({chr(true_byte)!r})  recovered={got:#04x} ({chr(got)!r})  match={got == true_byte}")

print(f"\nrecovered message = {bytes(recovered_bytes)!r}")
print(f"matches original secret message? {bytes(recovered_bytes) == SECRET_MESSAGE}")
print("\nnote: the attacker NEVER called anything resembling `read(victim_memory)` --")
print("every recovered byte came only from measuring which of 256 probe lines was fast.")

প্রকৃত চালিয়ে পাওয়া output (deterministic — কোনো real-time measurement নেই, সিমুলেশন সম্পূর্ণ, তাই হুবহু এটাই বারবার আসবে):

real secret byte (attacker cannot see this) = 0x41 ('A')

--- round 1: flush all 256 lines, victim touches ONE, attacker probes all 256 ---
attacker recovered byte = 0x41 ('A')
matches real secret?    = True
timing on recovered line 0x41 =   4 cycles (HIT)
timing on a random OTHER line 0x00 = 200 cycles (MISS)
how many of the 256 probed lines came back as a HIT? 1 (exactly the touched one)

--- round 2: recover a whole multi-byte secret, one byte at a time ---
byte 0: true=0x43 ('C')  recovered=0x43 ('C')  match=True
byte 1: true=0x53 ('S')  recovered=0x53 ('S')  match=True

recovered message = b'CS'
matches original secret message? True

note: the attacker NEVER called anything resembling `read(victim_memory)` --
every recovered byte came only from measuring which of 256 probe lines was fast.

মূল মুহূর্ত: 256টা probe line-এর মধ্যে ঠিক 1টাই HIT হিসেবে ফেরত এসেছে — বাকি 255টা সবসময় MISS (200 cycle)। এই একটা bit-level পার্থক্য (গরম বনাম ঠান্ডা) দিয়েই পুরো byte value (0-255-এর মধ্যে যেকোনোটা) নির্ভুলভাবে বের করা গেছে। রাউন্ড ২ দেখায় এই কৌশল একটা মাত্র byte-এ সীমাবদ্ধ না — flush-touch-reload চক্র একবারে একটা করে byte, ধারাবাহিকভাবে চালিয়ে যেকোনো length-এর secret পুনরুদ্ধার করা যায়। এই দুইটা primitive — timing attack (Experiment ১) আর cache-timing covert channel (Experiment ২) — এখন পরের দুই সেকশনের হাতিয়ার: Meltdown আর Spectre দুইটাই ঠিক এই একই Flush+Reload কৌশল ব্যবহার করে শেষ ধাপে, পার্থক্য শুধু কীভাবে victim-কে secret-নির্ভর cache access করতে বাধ্য করা হয়।

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

একটা victim-এর একটামাত্র memory access-ই ২৫৬টা সম্ভাব্য 'probe line'-এর মধ্যে ঠিক একটাকে গরম করে দেয়; attacker সেই একটা line খুঁজে বের করেই secret byte-এর সঠিক মান বলে দিতে পারে, ধারাবাহিকভাবে একটা সম্পূর্ণ multi-byte secret পুনরুদ্ধার করে — victim-এর memory কখনো সরাসরি না পড়েই।

নিজে বানান

BUILD IT

MeltdownSpectreSim — permission-check bypass বনাম branch-predictor mistraining

Python · ●●●●●
  1. আগের Experiment-এর SimulatedCache আর Flush+Reload primitive পুনর্ব্যবহার করুন
  2. Meltdown অংশ: একটা speculative_read_and_leak() লিখুন যেখানে cache-access permission check-এর আগেই ঘটে, আর permission check fail করলে PermissionError ওঠে — কিন্তু cache touch ততক্ষণে হয়ে গেছে
  3. দেখান architectural read প্রতিবারই ব্যর্থ হয় (exception ওঠে), তবু cache side-channel দিয়ে secret সম্পূর্ণ পুনরুদ্ধার হয়
  4. Spectre অংশ: branch-prediction লেসনের 2-bit saturating counter পুনর্ব্যবহার করে একটা bounds-check predictor বানান, in-bounds call দিয়ে "train" করুন
  5. একটা out-of-bounds index দিয়ে victim ফাংশন কল করুন — predictor এখনও "in-bounds" predict করছে বলে speculative body চলে, adjacent secret memory-কে cache-এ touch করে ফেলে, যদিও real bounds check শেষমেশ ঠিকই ধরে ফেলে (IndexError ওঠে)
  6. দুইটা আক্রমণের boundary-র পার্থক্য নোট করুন — Meltdown একটা privilege boundary (user/kernel) পার হয়, Spectre একটা logical bounds-check পার হয় — আর কেন এই পার্থক্যটাই তাদের mitigation-ও ভিন্ন করে দেয়

লক্ষ্য: আগের দুইটা Experiment আলাদা করে দেখিয়েছে টাইমিং সিগন্যাল আর cache covert channel কীভাবে কাজ করে। এখানে সেই একই cache-channel-কে speculative execution-এর দুইটা ভিন্ন ট্রিগার-এর সাথে জুড়ে দেখানো হবে — একটাতে একটা permission check বাইপাস হয় (Meltdown), আরেকটাতে একটা bounds check বাইপাস হয় predictor-কে ভুল শিখিয়ে (Spectre)।

# meltdown_spectre_sim.py -- simplified simulations of the Meltdown and Spectre
# mechanisms, built on the same SimulatedCache / Flush+Reload primitives as the
# earlier cache_covert_channel.py. Nothing here measures a real CPU -- it models
# the two mechanisms directly: (1) speculative execution touches the cache BEFORE
# a check resolves, and (2) the CPU's rollback restores ARCHITECTURAL state
# (registers, memory, return values) but never restores MICROARCHITECTURAL state
# (which cache lines are hot) -- so the side channel survives the rollback.

CACHE_HIT_CYCLES = 4
CACHE_MISS_CYCLES = 200
NUM_LINES = 256

class SimulatedCache:
    def __init__(self):
        self.cached = set()

    def flush_all(self):
        self.cached.clear()

    def access(self, line: int) -> int:
        hit = line in self.cached
        self.cached.add(line)
        return CACHE_HIT_CYCLES if hit else CACHE_MISS_CYCLES

def attacker_recover_byte(cache, probe_base):
    """FLUSH+RELOAD (Yarom & Falkner, 2014) -- identical to cache_covert_channel.py."""
    timings = {c: cache.access(probe_base + c) for c in range(NUM_LINES)}
    return min(timings, key=timings.get)

# =====================================================================
# PART 1 -- Meltdown: speculative read PAST A PERMISSION CHECK
# =====================================================================
print("=" * 70)
print("PART 1: Meltdown -- permission check bypassed by speculation")
print("=" * 70)

KERNEL_SECRET = b"root"   # stand-in for privileged kernel memory a user process cannot read

class MeltdownCPU:
    def __init__(self):
        self.cache = SimulatedCache()

    def speculative_read_and_leak(self, is_privileged: bool, secret_byte: int, probe_base: int):
        """Models: `if (check_permission(addr)) { v = *addr; probe[v * 4096]; }`
        On real hardware, the out-of-order engine does not WAIT for
        check_permission() to resolve before issuing the dependent load and the
        array-index -- it runs them speculatively first, using whatever byte
        sits at `addr`, regardless of whether the permission check will pass."""
        # (1) speculative execution happens FIRST and unconditionally --
        #     this line runs before the permission check below is evaluated
        self.cache.access(probe_base + secret_byte)

        # (2) only afterward does the permission check "catch up" -- on a
        #     real CPU this fires a fault, the pipeline is flushed, and the
        #     ARCHITECTURAL result (the register that would have held the
        #     byte) is discarded. The program never sees the value.
        if is_privileged:
            raise PermissionError("privileged memory -- architectural read blocked")
        return secret_byte   # normal, legitimate case: no exception, real value returned

cpu = MeltdownCPU()
print(f"real KERNEL_SECRET (attacker cannot read this legitimately) = {KERNEL_SECRET!r}\n")

recovered = bytearray()
architectural_successes = 0
for i, true_byte in enumerate(KERNEL_SECRET):
    cpu.cache.flush_all()
    try:
        value = cpu.speculative_read_and_leak(is_privileged=True, secret_byte=true_byte, probe_base=0)
        architectural_successes += 1
        print(f"byte {i}: architectural read SUCCEEDED (should never happen!) -> {value}")
    except PermissionError:
        pass   # exactly as expected -- the direct read is always blocked
    got = attacker_recover_byte(cpu.cache, 0)
    recovered.append(got)
    print(f"byte {i}: true={true_byte:#04x} ({chr(true_byte)!r})  "
          f"leaked-via-cache={got:#04x} ({chr(got)!r})  match={got == true_byte}")

print(f"\narchitectural reads that ever succeeded: {architectural_successes} (must be 0)")
print(f"recovered kernel secret (cache side-channel only) = {bytes(recovered)!r}")
print(f"fully recovered despite every direct read being blocked? {bytes(recovered) == KERNEL_SECRET}")

# =====================================================================
# PART 2 -- Spectre: mistrained branch predictor bypasses a BOUNDS check
# =====================================================================
print()
print("=" * 70)
print("PART 2: Spectre -- bounds check bypassed by a mistrained predictor")
print("=" * 70)

# Same 2-bit saturating counter from computer-architecture/branch-prediction.mdx,
# repurposed here to predict the OUTCOME OF A BOUNDS CHECK instead of a branch:
# state >= 2 means "predict in-bounds" (speculatively run the array access).
class TwoBitPredictor:
    def __init__(self):
        self.state = 3   # start Strongly-predict-in-bounds

    def predicts_in_bounds(self) -> bool:
        return self.state >= 2

    def update(self, actually_in_bounds: bool):
        self.state = min(3, self.state + 1) if actually_in_bounds else max(0, self.state - 1)

ARRAY1 = bytes([10, 20, 30, 40, 50])   # the public, bounds-checked array; length 5
SECRET_DATA = b"pwd!"                   # lives immediately after ARRAY1 in memory
MEMORY = ARRAY1 + SECRET_DATA           # attacker's OOB index reaches straight into it
ARRAY1_SIZE = len(ARRAY1)

def victim_function(cache, predictor, x, probe_base):
    """Models: `if (x < array1_size) { y = array1[x]; probe[y * 4096]; }`
    The bounds check `x < array1_size` is itself a branch -- and branches get
    predicted, exactly like the ones in branch-prediction.mdx. A predictor
    mistrained to expect 'in bounds' lets speculative execution run the body
    (the array read + cache-touching probe access) BEFORE the real check
    resolves -- even for an `x` that is architecturally out of bounds."""
    if predictor.predicts_in_bounds():
        # speculative body: runs immediately on the PREDICTION, not the fact
        if x < len(MEMORY):          # (speculation still needs a backing address;
            value = MEMORY[x]        #  real Spectre PoCs rely on this being true
            cache.access(probe_base + value)   # for adjacent secret data)

    real_in_bounds = x < ARRAY1_SIZE
    predictor.update(real_in_bounds)
    if not real_in_bounds:
        raise IndexError("array1 index out of range (blocked architecturally)")
    return MEMORY[x]

cache = SimulatedCache()
predictor = TwoBitPredictor()

print(f"real SECRET_DATA (lives just past ARRAY1 in memory, attacker cannot read it directly) = {SECRET_DATA!r}\n")

print("--- training phase: many in-bounds calls, exactly like a normal, honest use of the API ---")
for i in range(20):
    x = i % ARRAY1_SIZE
    victim_function(cache, predictor, x, probe_base=0)
print(f"predictor state after training = {predictor.state} (>=2 means 'predict in-bounds')")

print("\n--- attack phase: re-train right before each OOB call (real Spectre PoCs do exactly this,")
print("    since each successful misprediction nudges the counter back toward 'in-bounds') ---")
recovered_spectre = bytearray()
architectural_successes = 0
for i in range(len(SECRET_DATA)):
    for _ in range(5):   # short re-training burst, purely in-bounds, keeps predictor confident
        victim_function(cache, predictor, i % ARRAY1_SIZE, probe_base=0)
    x = ARRAY1_SIZE + i   # architecturally out of bounds -- reaches into SECRET_DATA
    cache.flush_all()
    try:
        value = victim_function(cache, predictor, x, probe_base=0)
        architectural_successes += 1
        print(f"byte {i}: architectural read SUCCEEDED (should never happen!) -> {value}")
    except IndexError:
        pass   # exactly as expected -- the real bounds check always eventually fires
    got = attacker_recover_byte(cache, 0)
    recovered_spectre.append(got)
    true_byte = SECRET_DATA[i]
    print(f"byte {i}: true={true_byte:#04x} ({chr(true_byte)!r})  "
          f"leaked-via-cache={got:#04x} ({chr(got)!r})  match={got == true_byte}  "
          f"predictor state after this call={predictor.state}")

print(f"\narchitectural reads that ever succeeded: {architectural_successes} (must be 0)")
print(f"recovered secret (cache side-channel only) = {bytes(recovered_spectre)!r}")
print(f"fully recovered despite every bounds check firing? {bytes(recovered_spectre) == SECRET_DATA}")

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

======================================================================
PART 1: Meltdown -- permission check bypassed by speculation
======================================================================
real KERNEL_SECRET (attacker cannot read this legitimately) = b'root'

byte 0: true=0x72 ('r')  leaked-via-cache=0x72 ('r')  match=True
byte 1: true=0x6f ('o')  leaked-via-cache=0x6f ('o')  match=True
byte 2: true=0x6f ('o')  leaked-via-cache=0x6f ('o')  match=True
byte 3: true=0x74 ('t')  leaked-via-cache=0x74 ('t')  match=True

architectural reads that ever succeeded: 0 (must be 0)
recovered kernel secret (cache side-channel only) = b'root'
fully recovered despite every direct read being blocked? True

======================================================================
PART 2: Spectre -- bounds check bypassed by a mistrained predictor
======================================================================
real SECRET_DATA (lives just past ARRAY1 in memory, attacker cannot read it directly) = b'pwd!'

--- training phase: many in-bounds calls, exactly like a normal, honest use of the API ---
predictor state after training = 3 (>=2 means 'predict in-bounds')

--- attack phase: re-train right before each OOB call (real Spectre PoCs do exactly this,
    since each successful misprediction nudges the counter back toward 'in-bounds') ---
byte 0: true=0x70 ('p')  leaked-via-cache=0x70 ('p')  match=True  predictor state after this call=2
byte 1: true=0x77 ('w')  leaked-via-cache=0x77 ('w')  match=True  predictor state after this call=2
byte 2: true=0x64 ('d')  leaked-via-cache=0x64 ('d')  match=True  predictor state after this call=2
byte 3: true=0x21 ('!')  leaked-via-cache=0x21 ('!')  match=True  predictor state after this call=2

architectural reads that ever succeeded: 0 (must be 0)
recovered secret (cache side-channel only) = b'pwd!'
fully recovered despite every bounds check firing? True

দুইটা অংশেই architectural reads that ever succeeded: 0 — এই লাইনটাই সবচেয়ে গুরুত্বপূর্ণ। প্রতিবার সরাসরি read চেষ্টা করলেই exception উঠেছে (PermissionError Meltdown-এ, IndexError Spectre-এ) — ঠিক যেমনটা একটা সঠিকভাবে-লেখা, memory-safe প্রোগ্রামে হওয়ার কথা। তবু fully recovered ... True — কারণ leak কখনো architectural read-এর মধ্য দিয়ে ঘটেইনি, ঘটেছে speculative execution-এর রেখে যাওয়া cache-fingerprint-এর মধ্য দিয়ে, যেটা exception ওঠার আগেই স্থায়ী হয়ে গিয়েছিল। PART 1 আর PART 2-এর মধ্যে পার্থক্যটা লক্ষ্য করুন — Meltdown-এ speculation ট্রিগার হয়েছে একটা permission check-এর বিপরীতে (privilege boundary, user বনাম kernel), Spectre-এ ট্রিগার হয়েছে একটা bounds check-এর বিপরীতে, predictor-কে বারবার in-bounds call দিয়ে “প্রশিক্ষণ” দিয়ে (logical boundary, array-এর ঘোষিত সীমা)। এই একটা পার্থক্যই realworld সেকশনে দেখা যাবে কেন দুইটার mitigation সম্পূর্ণ আলাদা রাস্তায় গেছে।

নিজে বাড়ান:

  1. Meltdown অংশে is_privileged=False দিয়ে চালিয়ে দেখুন — architectural read কেন এবার সফল হয়, আর architectural_successes সংখ্যাটা কীভাবে বদলায়
  2. Spectre অংশে training burst সংখ্যা (5) কমিয়ে 1 করুন — কয়টা attack byte-এ predictor আসলে “মিসপ্রেডিক্ট থেকে বেরিয়ে” যায় (state < 2-এ নেমে যায়) আর সেই byte-এর leak ব্যর্থ হয় দেখুন; এটাই বাস্তব Spectre PoC-তে training-burst-সাইজ একটা tunable প্যারামিটার কেন
  3. victim_function-এ if x < len(MEMORY) শর্তটা সরিয়ে ফেলুন এবং ব্যাখ্যা করুন কেন বাস্তব হার্ডওয়্যারে speculation-এরও একটা সীমা আছে (out-of-bounds access করলেও সেটা কোনো mapped page-এর মধ্যেই হতে হয়, নাহলে speculative execution নিজেই একটা page fault-এ থেমে যায় — Spectre PoC-গুলো তাই সবসময় “কাছাকাছি,” mapped memory-ই টার্গেট করে)

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

বাস্তব জগতে — একই আবিষ্কার, দুইটা ভিন্ন মেরামতের পথ

Meltdown (CVE-2017-5754) — KPTI দিয়ে কার্যত সমাধান হয়ে গেছে। Meltdown-এর মূল সমস্যা: user-space code speculatively এমন একটা ঠিকানায় read করতে পারত যেটা তার নিজের page table-এই ম্যাপ করা ছিল (পারফরম্যান্সের কারণে kernel memory প্রায়ই user process-এর একই address space-এ ম্যাপ করা থাকত, শুধু একটা permission bit দিয়ে সুরক্ষিত)। সমাধান — KPTI (Kernel Page Table Isolation) — user আর kernel-এর জন্য সম্পূর্ণ আলাদা page table ব্যবহার করা, যাতে user-mode-এ চলার সময় kernel memory-র ঠিকানাই আর user process-এর page table-এ উপস্থিত না থাকে — speculative access করারই কিছু নেই। খরচ: প্রতিটা user-kernel transition (system call)-এ page-table switch, যা কিছু syscall-heavy workload-এ (~৫-৩০%) measurable performance regression এনেছিল, যদিও বেশিরভাগ সাধারণ workload-এ এটা ~১-২%-এর মধ্যেই।

Spectre (CVE-2017-5753 variant 1, CVE-2017-5715 variant 2) — এখনও একটা চলমান যুদ্ধ। Spectre-এর মূল সমস্যা branch predictor নিজেই — আর predictor প্রায়ই শেয়ার করা hardware resource (একই core-এর বিভিন্ন প্রসেসের মধ্যে, বা hyper-threading-এ একই সময়ে দুইটা প্রসেসের মধ্যে)। এর মানে predictor-কে ভুল শেখানো শুধু নিজের প্রসেসের মধ্যেই না, অন্য প্রসেসকেও প্রভাবিত করতে পারে — একটা malicious প্রসেস আরেকটা victim প্রসেসের bounds-check predictor-কে mistraining করে দিতে পারে, কোনো permission-boundary অতিক্রম না করেই। মূল mitigation-গুলো তাই সম্পূর্ণ ভিন্ন প্রকৃতির: retpoline (compiler-level কৌশল যা indirect branch-কে একটা নিয়ন্ত্রিত, predictable রূপে রূপান্তরিত করে যাতে attacker-controlled target speculatively execute না হয়), IBRS/IBPB (Indirect Branch Restricted Speculation / Predictor Barrier — hardware-level নির্দেশ যা প্রসেস-পরিবর্তনে predictor state আলাদা করে), আর compiler-inserted lfence (একটা speculation-barrier instruction যা নির্দিষ্ট ঝুঁকিপূর্ণ কোড-প্যাটার্নে বসানো হয়, বিশেষত bounds-check-এর পরে)।

Browser-এর ভেতরেও ঝুঁকি — JavaScript timing attack। মূল Spectre পেপারেই দেখানো হয়েছিল আক্রমণটা শুধু native code-এ সীমাবদ্ধ না — JavaScript-এর performance.now()-জাতীয় timer আর সাধারণ array-access দিয়েই একটা ওয়েবপেজ ভিজিটরের browser process-এর memory থেকে (অন্য ট্যাবের ডেটা-সহ) তথ্য leak করানো সম্ভব প্রমাণিত হয়েছিল। এর প্রতিক্রিয়ায় browser vendor-রা দুইটা defense একসাথে করেছে: timer resolution কমানো (যাতে nanosecond-স্তরের timing measurement করা কঠিন হয়) আর site isolation (প্রতিটা origin-কে আলাদা OS process-এ চালানো, যাতে একটা মালিশাস origin-এর speculative leak অন্য origin-এর memory-তে পৌঁছাতেই না পারে — একটা process-boundary দিয়ে সমাধান, ঠিক KPTI-র যুক্তির প্রতিধ্বনি, শুধু ভিন্ন স্তরে)।

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

“Meltdown আর Spectre সাধারণ software bug — একটা patch দিয়েই পুরোপুরি ঠিক হয়ে যায়, ঠিক buffer overflow-এর মতো।”

সম্পূর্ণ ভুল একটা মৌলিক অর্থে। Buffer overflow একটা কোডিং ভুল — একটা bounds-check না থাকা, যা যোগ করলেই bug মুছে যায়। Meltdown/Spectre কোনো bug কাজে লাগায় না — এরা speculative execution-এর সঠিক, ইচ্ছাকৃত আচরণ কাজে লাগায় (CPU-কে দ্রুত করার জন্যই speculation ডিজাইন করা হয়েছিল)। তাই “fix” মানে হয় একটা সম্পূর্ণ নতুন hardware-স্তরের বাধা তৈরি করা (KPTI-র মতো বাড়তি page-table isolation) বা compiler-স্তরের কৌশল (retpoline), আর কোনোটাই বিনামূল্যে না — উভয়েরই measurable performance খরচ আছে, ঠিক কারণ এরা কোনো “ভুল” ঠিক করছে না, একটা intentional performance feature-কে সীমিত করছে।

“CPU-র rollback গ্যারান্টি নিশ্চিত করে speculative execution কোনো চিহ্ন রাখে না — তাই এটা নিরাপদ ছিল ভাবাই স্বাভাবিক ছিল।”

এখানেই পুরো লেসনের কেন্দ্রীয় বিভ্রান্তি। Rollback শুধু architectural state (register value, memory-তে দৃশ্যমান পরিবর্তন, program-এর “সরকারি” ফলাফল) রক্ষা করে — এটাই branch-prediction লেসনের misconception সেকশনের দাবি ছিল, আর সেটা এখনও সত্য। কিন্তু microarchitectural state (কোন cache line গরম, branch predictor-এর history table-এ কী আছে) — এগুলো কখনোই “rollback”-এর আওতায় ছিল না, কারণ সেগুলো কখনো “architecturally visible output” হিসেবে গণ্যই হয়নি। Meltdown আর Spectre-এর মূল আবিষ্কার ঠিক এইটাই: একটা অদৃশ্য state-ও যদি measurable হয়, সেটা একটা বৈধ leak-চ্যানেল — “invisible to the program” আর “invisible to measurement” এই দুইটা সম্পূর্ণ আলাদা দাবি।

“KPTI প্যাচ ইনস্টল হয়ে গেলে Meltdown আর Spectre দুইটাই বন্ধ, একই সমাধান দুইটাতেই কাজ করে।”

Realworld সেকশনে বিস্তারিত — KPTI শুধু Meltdown-এর জন্য কার্যকর, কারণ Meltdown-এর boundary page-table-স্তরের (kernel memory user-এর page table-এ ম্যাপড থাকা)। Spectre-এর boundary সম্পূর্ণ ভিন্ন — branch predictor-এর নিজস্ব শেখার প্রক্রিয়া, যেটা page table-এর সাথে সরাসরি সম্পর্কিত না। KPTI ইনস্টল করা একটা সিস্টেমেও Spectre variant ১ ও ২ সম্পূর্ণ কার্যকর থাকতে পারে, যদি না আলাদা mitigation (retpoline, IBRS/IBPB, compiler fence) প্রয়োগ করা হয় — দুইটা প্যাচ, দুইটা ভিন্ন বাগ-শ্রেণির জন্য, একটা আরেকটার substitute না।

“Timing/cache side-channel attack চালাতে বিশেষ physical hardware access (oscilloscope, probe) লাগে — সাধারণ remote/software attacker-এর জন্য এটা বাস্তবিক হুমকি না।”

Power analysis বা EM side channel-এর ক্ষেত্রে এটা আংশিক সত্য (সেগুলোতে physical proximity বা বিশেষ যন্ত্র লাগে)। কিন্তু timing আর cache-timing side channel সম্পূর্ণ ভিন্ন — এদের দরকার শুধু software-স্তরের একটা ঘড়ি (time.perf_counter()-জাতীয়) আর target operation বহুবার চালানোর ক্ষমতা। আজকের Experiment দুইটাই কোনো বিশেষ hardware ছাড়া, নিজের সাধারণ ল্যাপটপে চলেছে। বাস্তবে এই আক্রমণ remote network attacker (একটা HTTP API-তে response time মেপে) থেকে শুরু করে একটা ওয়েবপেজে বসা JavaScript পর্যন্ত — সবার কাছেই প্রযোজ্য, ঠিক এই কারণেই এত ব্যাপক প্রভাব ফেলেছিল।

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

1

Side-channel attack-এর সংজ্ঞায় “মূল চ্যানেল” আর “পার্শ্ব চ্যানেল”-এর মধ্যে পার্থক্য কী, আর আজকের দুইটা প্রধান পার্শ্ব-চ্যানেল কোনগুলো?

স্মরণ

মূল চ্যানেল হলো সিস্টেমের ঘোষিত input-output interface। পার্শ্ব চ্যানেল হলো যেকোনো পরিমাপযোগ্য পার্শ্ব-প্রভাব (timing, cache state, power, EM) যা secret data-র সাথে correlated, অথচ ডিজাইনার সেটাকে কখনো “output” হিসেবে গণ্য করেননি। আজকের দুইটা প্রধান পার্শ্ব-চ্যানেল: timing (কতক্ষণ সময় নিলো) আর cache-timing (কোন cache line গরম/ঠান্ডা) — দুইটাই কোনো বিশেষ hardware ছাড়া সাধারণ software থেকেই পরিমাপযোগ্য।

2

Experiment ১-এ naive_compare-এ একটা TERMINATOR byte যোগ করা হয়েছিল। এটা না থাকলে ঠিক কোন byte-এর timing signal হারিয়ে যেত, আর কেন?

যুক্তি

TERMINATOR ছাড়া, secret-এর শেষ byte-এর signal হারিয়ে যেত। কারণ: প্রতিটা আগের position-এ, সঠিক guess loop-কে এক ধাপ বেশি এগিয়ে দেয় (filler byte-এ গিয়ে মিসম্যাচ হয়), যা একটা measurable বাড়তি slow_step() যোগ করে। কিন্তু শেষ byte-এর পরে কোনো filler নেই — তাই সঠিক শেষ-byte guess আর ভুল শেষ-byte guess দুইটাই ঠিক একই সংখ্যক slow_step() চালাত (একটা পুরো comparison শেষ করে, একটা মাঝপথে থেমে গিয়ে) — কোনো পার্থক্য তৈরি হতো না। TERMINATOR যোগ করায় একটা সম্পূর্ণ-সঠিক guess সেই বাড়তি byte-টাও মেলাতে পারে (এক ধাপ বেশি), যেখানে ভুল শেষ-byte guess সেই ধাপ পর্যন্ত পৌঁছায়ই না — signal ফিরে আসে।

3

Experiment ২-এ CACHE_HIT_CYCLES = 4 আর CACHE_MISS_CYCLES = 200 কেন এই নির্দিষ্ট সংখ্যা বেছে নেওয়া হয়েছে, শুধু “দ্রুত” আর “ধীর” না বলে?

প্রয়োগ

এই দুইটা সংখ্যা কাল্পনিক না — সরাসরি আগের computer-architecture module-এর লেসন থেকে নেওয়া। cache-organization.mdx-এ দেখানো হয়েছিল L1 cache lookup-এর latency ~4 cycle-এর কাছাকাছি (decode + parallel tag-compare

  • mux, একটা পাইপলাইনড hardware ধাপ)। superscalar-and-out-of-order.mdx-এ দেখানো হয়েছিল একটা cache miss (DRAM পর্যন্ত যেতে) ~200 cycle পর্যন্ত লাগতে পারে। এই দুইটা বাস্তব সংখ্যা ব্যবহার করে সিমুলেশনটা শুধু ধারণাগত না, বাস্তব হার্ডওয়্যারের সাথে সামঞ্জস্যপূর্ণ একটা মডেল — ~৫০ গুণ ব্যবধানই আসল কারণ কেন Flush+Reload measurable।
4

BuildIt-এর output-এ architectural reads that ever succeeded: 0 লাইনটা দুইটা অংশেই দেখা গেছে, তবু secret দুইটাতেই সম্পূর্ণ পুনরুদ্ধার হয়েছে। এই দুইটা তথ্য একসাথে ঠিক কী প্রমাণ করে, আর কেন এটা “correctness” আর “confidentiality”-কে আলাদা প্রশ্ন হিসেবে দেখায়?

যুক্তি

এটা প্রমাণ করে CPU-র correctness গ্যারান্টি (program কখনো ভুল বা অননুমোদিত মান দেখবে না) সম্পূর্ণ অক্ষত ছিল — protection mechanism (permission check, bounds check) প্রতিবারই সঠিকভাবে কাজ করেছে, architectural স্তরে। অথচ confidentiality গ্যারান্টি (secret data বাইরে বের হবে না) সম্পূর্ণ ভেঙে পড়েছে, কারণ leak architectural read-এর মধ্য দিয়ে ঘটেইনি — ঘটেছে speculative execution-এর cache-fingerprint-এর মধ্য দিয়ে। এটাই দেখায় “correctness” (program সঠিক উত্তর দেয়) আর “confidentiality” (secret গোপন থাকে) দুইটা স্বাধীন গ্যারান্টি — একটা রক্ষা পাওয়া মানেই আরেকটা রক্ষা পাওয়া না, ঠিক hash-functions লেসনের preimage/collision resistance-এর মতোই স্বাধীন axis।

5

Spectre অংশে predictor.state-এর মান attack loop-এর প্রতিটা byte-এর পরে 2-তে নেমে আসছে (আউটপুটে দেখা যায়), 3-এ ফেরত যাচ্ছে না। এটা কেন ঘটছে, আর যদি re-training burst (৫টা কল) সম্পূর্ণ বাদ দেওয়া হতো, আক্রমণের ফলাফল কী হতো বলে আপনার ধারণা?

প্রয়োগ

প্রতিটা OOB attack-call-এ real_in_bounds = False হওয়ায় predictor এক ধাপ কমে (3→2), কারণ প্রতিটা কলই architecturally ভুল প্রমাণিত হয় (IndexError ওঠে)। Re-training burst (৫টা in-bounds কল) সেই ক্ষতি পরের attack-call-এর আগেই পুষিয়ে দেয় (state আবার 3-এ ফেরত যায়, তারপর attack-call-এ আবার 2-এ নামে — predicts_in_bounds()-এর জন্য >=2 যথেষ্ট, তাই speculation তবু ঘটে)। যদি re-training সম্পূর্ণ বাদ দেওয়া হতো, প্রতিটা পরপর OOB attack-call predictor-কে আরও নামাত (2→1→0) — state < 2-এ পৌঁছালে predicts_in_bounds() False হয়ে যেত, speculative body আর চলতই না, আর সেই byte-এর (এবং তার পরের সব byte-এর, যতক্ষণ না আবার training হয়) cache-leak সম্পূর্ণ ব্যর্থ হতো — এটাই বাস্তব Spectre PoC-তে re-training একটা প্রয়োজনীয়, tunable ধাপ কেন।

6

আপনি একটা multi-tenant cloud platform ডিজাইন করছেন যেখানে ভিন্ন গ্রাহকের ভার্চুয়াল মেশিন একই physical CPU core-এ (hyper-threading-এর মাধ্যমে) শেয়ার হতে পারে। Meltdown/Spectre-এর আজকের শিক্ষা অনুযায়ী এই ডিজাইনের ঠিক কোন ঝুঁকিটা সবচেয়ে গুরুত্বপূর্ণ, আর কী architectural সিদ্ধান্ত নেবেন?

ডিজাইন

সবচেয়ে গুরুত্বপূর্ণ ঝুঁকি: হাইপার-থ্রেডিং-এ দুইটা logical core একই L1/L2 cache আর একই branch predictor hardware শেয়ার করে (Percival-এর ২০০৫-এর কাজের ঠিক যে সেটআপ) — একটা গ্রাহকের VM অন্য গ্রাহকের VM-এর cache-timing বা branch-predictor state পর্যবেক্ষণ করতে পারে, ঠিক আজকের Flush+Reload/Spectre কৌশলে, কোনো hypervisor bug ছাড়াই, কারণ শেয়ার করা hardware resource নিজেই চ্যানেল। ডিজাইন-সিদ্ধান্ত: নিরাপত্তা-সংবেদনশীল বা different-tenant workload-এর জন্য hyper-threading বন্ধ রাখা বা core-level isolation নিশ্চিত করা (প্রতি tenant-কে সম্পূর্ণ physical core বরাদ্দ, sibling logical core একই tenant-এর বাইরে কাউকে না দেওয়া) — এটাই বাস্তবে বড় cloud provider-রা (AWS, GCP) Spectre/L1TF-এর পরে গ্রহণ করেছিল “same-tenant scheduling” নীতি হিসেবে। শুধু software-level mitigation (KPTI, retpoline) যথেষ্ট না যখন hardware resource নিজেই একাধিক নিরাপত্তা-ডোমেইনের মধ্যে শেয়ার হচ্ছে — এটা এই module-এর “mitigation attack কঠিন করে, ভিত্তিগত ঝুঁকি মুছে ফেলে না” থিমেরই একটা cloud-স্কেল সংস্করণ।

এরপর কী

এরপর কী

আজ একটা সম্পূর্ণ নতুন আক্রমণ-পরিবার দেখা হলো — যেখানে কোনো memory-safety bug নেই, কোনো crafted input নেই, শুধু প্রোগ্রাম কীভাবে চলছে তার পরিমাপযোগ্য চিহ্ন। Timing side channel থেকে শুরু করে cache-timing covert channel পর্যন্ত, আর সেখান থেকে speculative execution-এর architectural-বনাম-microarchitectural rollback-এর ফাঁক ব্যবহার করে Meltdown (permission check বাইপাস) আর Spectre (branch predictor mistraining) — দুইটাই একই শেষ ধাপে মিলেছে: cache-timing দিয়ে গোপন তথ্য পড়া। KPTI Meltdown-কে কার্যত বন্ধ করে দিয়েছে, কিন্তু Spectre আজও খোলা যুদ্ধক্ষেত্র, কারণ এটা speculation-এর মৌলিক প্রক্রিয়া নিজেই কাজে লাগায়, কোনো নির্দিষ্ট bug না।

এখন পর্যন্ত এই module-এর প্রতিটা লেসন একটা সাধারণ ধরে নেওয়া বিষয়ের ওপর দাঁড়িয়ে ছিল — কোডটা আপনি নিজে লিখেছেন। Buffer overflow, use-after-free, injection, side channel — প্রতিটাই আপনার নিজের লেখা (বা আপনার নিয়ন্ত্রণাধীন) কোড আর হার্ডওয়্যারের মধ্যেকার আক্রমণ। কিন্তু আজকের বাস্তব সফটওয়্যারের সিংহভাগ — গড়ে একটা প্রোজেক্টের ৮০%-এরও বেশি কোড — আসে এমন dependency থেকে যা আপনি কখনো পড়েননি, শুধু npm install বা pip install দিয়ে বিশ্বাস করে বসিয়ে দিয়েছেন। পরের লেসনে — supply-chain security — আমরা ঠিক সেই শেষ, সবচেয়ে বড় আক্রমণ-পৃষ্ঠে যাচ্ছি: এমন কোড যা আপনি লেখেনইনি, তবু আপনার প্রোগ্রাম নিঃশর্তভাবে বিশ্বাস করে।

আরও পড়ুন

  • Spectre Attacks: Exploiting Speculative Execution — Paul Kocher, Jann Horn, Anders Fogh, Daniel Genkin, Daniel Gruss, Werner Haas, Mike Hamburg, Moritz Lipp, Stefan Mangard, Thomas Prescher, Michael Schwarz, Yuval Yarom — IEEE Symposium on Security and Privacy, 2019 · Spectre-এর প্রতিষ্ঠাতা পেপার — branch predictor-কে mistraining করে speculative execution-এর মাধ্যমে cross-process/cross-privilege তথ্য leak করার কৌশল প্রথম প্রকাশিত হয় এখানেই
  • Meltdown: Reading Kernel Memory from User Space — Moritz Lipp, Michael Schwarz, Daniel Gruss, Thomas Prescher, Werner Haas, Anders Fogh, Jann Horn, Stefan Mangard, Paul Kocher, Daniel Genkin, Yuval Yarom, Mike Hamburg — USENIX Security Symposium, 2018 · Meltdown-এর প্রতিষ্ঠাতা পেপার — permission check শেষ হওয়ার আগেই speculative read কীভাবে kernel memory-র বিষয়বস্তু user process-এ leak করে দেয় তার সম্পূর্ণ বর্ণনা
  • FLUSH+RELOAD: A High Resolution, Low Noise, L3 Cache Side-Channel Attack — Yuval Yarom, Katrina Falkner — USENIX Security Symposium, 2014 · আজকের experiment section-এ সিমুলেট করা cache-timing covert channel-এর মূল কৌশল — flush, victim চলতে দাও, reload করে timing মাপো — এখানেই আনুষ্ঠানিকভাবে প্রকাশিত
  • Cache Missing for Fun and Profit — Colin Percival — BSDCan, 2005 · Flush+Reload-এর আগে cache-timing side channel-এর প্রথম দিকের একটা গুরুত্বপূর্ণ কাজ — hyper-threading-এ শেয়ার করা cache থেকে AES key leak করার প্রদর্শন