এই পর্বে যা শিখবেন
- Implementation-defined, unspecified আর undefined — তিন রকম আচরণের পার্থক্য
- পুরো কোর্সে ছড়িয়ে থাকা UB-গুলো এক তালিকায়, আর C-তে UB থাকার আসল কারণ
- Optimizer কীভাবে আপনার overflow-check বা null-check মুছে দিতে পারে
- Buffer overflow আর size-হিসাবের integer overflow কেন নিরাপত্তার সবচেয়ে বড় ঝুঁকি, আর কীভাবে ঠেকাবেন
- নিরাপদ কোডের অভ্যাস, compiler flag, OS-এর সুরক্ষা ও coding standard — আর AI-এর লেখা C কোড যাচাইয়ের চেকলিস্ট
১ · C স্ট্যান্ডার্ড একটি চুক্তিপত্র
এই কোর্সে অনেকগুলো পর্বে একটি শব্দ বারবার এসেছে — undefined behavior, সংক্ষেপে UB. পর্ব ০৭-এ i = i++, পর্ব ১৬-তে array-র সীমার বাইরে পড়া, পর্ব ১৯-এ buffer overflow, পর্ব ২০-এ free করা মেমরি আবার ব্যবহার, পর্ব ৪৫-এ strict aliasing. প্রতিবার আমরা বলেছি “এটি UB, করবেন না।” এই পর্বে সেই সব টুকরো এক জায়গায় জুড়ব। আর দেখব, কেন এই ছোট ছোট ভুলই পৃথিবীর সবচেয়ে বড় নিরাপত্তা-বিপর্যয়গুলোর উৎস।
শুরু করি একটি মৌলিক কথা দিয়ে। C ভাষার মালিক কোনো একটি compiler নয়। ভাষাটির নিয়ম লেখা আছে একটি আন্তর্জাতিক দলিলে — ISO C standard-এ। GCC, Clang, Microsoft-এর compiler, এমনকি ছোট microcontroller-এর compiler — সবাই এই দলিল মেনে চলার চেষ্টা করে।
ধরুন আপনি ঢাকায় একটি ফ্ল্যাট ভাড়া নিলেন। চুক্তিপত্রে লেখা: “ভাড়াটিয়া দেয়াল ভাঙবেন না, ছাদে পানির ট্যাংক বসাবেন না।” আপনি নিয়ম মানলে বাড়িওয়ালা কথা দিচ্ছেন — পানি, বিদ্যুৎ, নিরাপত্তা সব ঠিক থাকবে। কিন্তু আপনি যদি দেয়াল ভেঙে ফেলেন, তখন চুক্তিপত্র আর কিছুই বলে না। ছাদ ধসে পড়তে পারে, কিছু নাও হতে পারে, বা ছয় মাস পরে অন্য ঘরে ফাটল দেখা দিতে পারে। C-তে UB ঠিক সেই “দেয়াল ভাঙা” — এরপর কী হবে, চুক্তিতে লেখা নেই।
এখানেই নতুনদের সবচেয়ে বড় ভুল ধারণা। তাঁরা ভাবেন, “UB মানে হয়তো ভুল একটা সংখ্যা আসবে, বা প্রোগ্রাম crash করবে।” না। স্ট্যান্ডার্ড আক্ষরিক অর্থেই বলে, এই অবস্থায় আচরণের ওপর কোনো শর্ত নেই। ফল ঠিক আসতে পারে, ভুল আসতে পারে, crash হতে পারে, আবার আপনার লেখা অন্য একটি if নিঃশব্দে উধাও হয়ে যেতে পারে।
Compiler ধরে নেয় আপনার প্রোগ্রামে কখনো UB ঘটে না। এই অনুমানের ওপর দাঁড়িয়েই সে কোড দ্রুত করে। তাই UB-ওয়ালা কোড “আমার কম্পিউটারে চলছে” মানে শুধু এটুকু — আজ, এই compiler-এ, এই flag-এ, এই ইনপুটে চলছে। কাল -O2 দিলে, compiler আপডেট করলে বা অন্য CPU-তে নিলে একই কোড অন্য রকম আচরণ করতে পারে, এবং সেটি compiler-এর দোষ নয়।
১৯৯২ সালের দিকে Usenet-এর comp.std.c আলোচনা গ্রুপে একজন প্রোগ্রামার মজা করে লিখেছিলেন — UB ঘটলে compiler চাইলে “আপনার নাক দিয়ে দানব (demon) উড়িয়ে দিতে পারে”, তাতেও স্ট্যান্ডার্ড ভাঙা হবে না। সেই থেকে প্রোগ্রামারদের মধ্যে nasal demons কথাটি UB-এর ডাকনাম। কথাটি হাস্যকর, কিন্তু মূল বার্তাটি খুবই গম্ভীর: UB-এর পর কোনো কিছুরই নিশ্চয়তা নেই।
C-তে UB রাখা হলো কেন?
প্রশ্ন জাগতেই পারে — এত বিপজ্জনক জিনিস ভাষায় রাখার দরকার কী? উত্তর দুটি শব্দে: portability আর গতি।
- সব যন্ত্রে চলা: ১৯৮০-র দশকে C যখন প্রমিত হচ্ছিল, তখন বাজারে নানা রকম CPU — কোনোটিতে ঋণাত্মক সংখ্যা রাখার নিয়ম আলাদা, কোনোটি ভুল ঠিকানায় পড়লে থেমে যায়, কোনোটি থামে না। সবার আচরণ একটি নিয়মে বাঁধলে অনেক যন্ত্রে প্রতিটি যোগ-বিয়োগের সাথে বাড়তি যাচাই লাগত।
- যাচাইয়ের খরচ না দেওয়া: array-র প্রতিটি ব্যবহারে সীমা যাচাই, প্রতিটি pointer ব্যবহারের আগে null যাচাই — এগুলো ভাষা নিজে করলে প্রোগ্রাম ধীর হতো। C এই দায়িত্ব প্রোগ্রামারের হাতে দিয়েছে।
- Optimizer-কে স্বাধীনতা দেওয়া: “signed সংখ্যা কখনো উপচে পড়ে না” — এই অনুমান থাকলে compiler লুপকে অনেক দ্রুত করতে পারে। অংশ ৪-এ আমরা দেখব, এই স্বাধীনতাই কখনো কখনো ভয়ংকর রূপ নেয়।
২ · তিন রকম “অনির্দিষ্টতা”
স্ট্যান্ডার্ড সব কিছু একভাবে নির্দিষ্ট করে না। কিছু জিনিস ইচ্ছা করেই খোলা রাখা হয়েছে। কিন্তু খোলা রাখার তিনটি ভিন্ন মাত্রা আছে, আর পার্থক্যটি জানা খুব জরুরি।
Implementation-defined
Implementation-defined আচরণে compiler নিজের মতো বেছে নিতে পারে, কিন্তু বেছে নেওয়াটা লিখিতভাবে জানাতে বাধ্য। যেমন int কত বাইট, char signed কি না। একই compiler-এ এটি সবসময় একই রকম।
Unspecified
Unspecified আচরণে কয়েকটি বৈধ ফলের যেকোনোটি হতে পারে, compiler জানাতেও বাধ্য নয়। যেমন f(a(), b())-তে a() আগে চলবে না b() — ঠিক করা নেই। কিন্তু দুটোই চলবে, এবং প্রোগ্রাম বৈধ থাকবে।
Undefined
স্ট্যান্ডার্ড কোনো শর্তই দেয় না। শুধু ওই লাইন নয়, পুরো প্রোগ্রামের আচরণ অনির্দিষ্ট হয়ে যায়। Compiler ধরে নেয় এটি কখনো ঘটবে না।
সবসময় এড়িয়ে চলুনএকটি রেস্তোরাঁর মেনু দিয়ে ভাবুন। Implementation-defined যেন “ভাতের সাথে ডাল দেওয়া হয় — কোন ডাল, তা প্রতিটি শাখার বোর্ডে লেখা।” Unspecified যেন “থালায় প্রথমে ভাত আসবে না মাছ, ঠিক নেই — তবে দুটোই আসবে।” আর undefined যেন “রান্নাঘরে ঢুকে নিজে চুলা ধরালে কী হবে, রেস্তোরাঁ কোনো দায় নেয় না।”
| কোড | ধরন | কেন |
|---|---|---|
sizeof(long) | Implementation-defined | Windows-এ সাধারণত ৪, ৬৪-বিট Linux-এ ৮ — দুটোই লিখিত |
-7 >> 1 (ঋণাত্মক সংখ্যা ডানে shift) | Implementation-defined | প্রায় সব compiler sign রেখে দেয়, কিন্তু স্ট্যান্ডার্ড বাধ্য করে না |
printf("%d %d", a(), b()) | Unspecified | কোন ফাংশন আগে চলবে, ঠিক করা নেই |
INT_MAX + 1 | Undefined | signed overflow |
1 << 40 (৩২-বিট int-এ) | Undefined | বিটের সংখ্যার চেয়ে বেশি shift (পর্ব ০৯) |
Unspecified আচরণ নিজে বিপজ্জনক নয়, কিন্তু UB-এর দিকে ঠেলে দিতে পারে। a() আর b() দুজনেই যদি একই global ভ্যারিয়েবল বদলায়, ফল ক্রমের ওপর নির্ভর করবে। আর একই expression-এ একটি ভ্যারিয়েবলকে দুবার বদলালে, মাঝে কোনো sequence point না থাকলে, সেটি সরাসরি UB — পর্ব ০৭-এর i = i++.
৩ · পুরো কোর্সের UB — এক তালিকায়
নিচের তালিকাটি মুখস্থ করার জন্য নয়, চিনে রাখার জন্য। কোড রিভিউ বা AI-এর কোড পড়ার সময় এই প্রতিটি জিনিসের দিকে চোখ রাখবেন। পাশে লেখা আছে কোন পর্বে বিস্তারিত দেখেছেন।
| UB | ছোট উদাহরণ | এক লাইনে | বিস্তারিত |
|---|---|---|---|
| Signed integer overflow | int x = INT_MAX; x + 1 | unsigned ঘুরে শূন্যে যায়, signed-এর কোনো নিয়ম নেই | পর্ব ০৫, ০৮ |
| সীমার বাইরে প্রবেশ | a[10] যেখানে int a[10] | out-of-bounds পড়া বা লেখা | পর্ব ১৬, ১৯ |
| Use-after-free, double free | free(p); *p = 1; | ফেরত দেওয়া মেমরিতে হাত দেওয়া | পর্ব ২০ |
| Null dereference | int *p = NULL; *p | null pointer দিয়ে পড়া বা লেখা | পর্ব ১৭ |
| Uninitialised পড়া | int x; printf("%d", x); | মান না বসানো local ভ্যারিয়েবল ব্যবহার | পর্ব ০৬ |
| Data race | দুটি thread একসাথে count++ | lock ছাড়া একসাথে লেখা | পর্ব ৪৯ |
| অতিরিক্ত shift | 1u << 32 (৩২-বিট) | বিটের সংখ্যার সমান বা বেশি, অথবা ঋণাত্মক shift | পর্ব ০৯ |
| String literal বদলানো | char *s = "abc"; s[0] = 'X'; | literal প্রায়ই read-only মেমরিতে থাকে | পর্ব ১৯ |
| Strict aliasing ভাঙা | *(int *)&f যেখানে f একটি float | এক টাইপের মেমরি অন্য টাইপের pointer দিয়ে পড়া | পর্ব ৪৫ |
| Unsequenced পরিবর্তন | i = i++ + 1; | এক expression-এ একই ভ্যারিয়েবল দুবার বদলানো | পর্ব ০৭ |
| Return মান হারানো | int f(void) { }, তারপর x = f(); | যে ফাংশন মান ফেরত দেয়নি, তার মান ব্যবহার | এই পর্বে নতুন (ফাংশন: পর্ব ১৩) |
| ভুল alignment | *(int *)(buf + 1) | যে ঠিকানা টাইপের alignment মানে না, সেখানে পড়া | পর্ব ২২ |
তালিকাটি খেয়াল করুন — বেশিরভাগই মেমরি নিয়ে। ভুল জায়গায় পড়া, ভুল জায়গায় লেখা, ভুল সময়ে লেখা। নিরাপত্তার জগতে এদের একসাথে বলা হয় মেমরি-নিরাপত্তার ভুল, বা memory safety bug. অংশ ৫-এ দেখব, কেন এরাই আক্রমণকারীদের সবচেয়ে প্রিয়।
Unsigned overflow UB নয়। unsigned সংখ্যা সীমা পেরোলে নিয়ম মেনে শূন্য থেকে আবার ঘুরে আসে — একে বলে wraparound, এবং এটি সম্পূর্ণ নির্দিষ্ট। কিন্তু নির্দিষ্ট মানেই নিরাপদ নয়! অংশ ৬-এ দেখবেন, বৈধ wraparound থেকে কীভাবে একটি ভয়ংকর heap overflow জন্ম নেয়।
৪ · Optimizer কীভাবে UB-কে কাজে লাগায়
এবার আসল চমক। Compiler বিদ্বেষী নয় — সে কেবল নিয়ম মেনে যুক্তি চালায়। কিন্তু “UB কখনো ঘটে না” — এই একটি অনুমান থেকে সে এমন সিদ্ধান্ত নিতে পারে, যা মানুষের চোখে অবিশ্বাস্য। সবচেয়ে বিখ্যাত উদাহরণটি ধাপে ধাপে দেখুন। একজন প্রোগ্রামার bKash-এর মতো একটি wallet-এর জন্য overflow যাচাই লিখেছেন:
- আপনার কোড
x + 1 < xoverflow ধরার চেষ্টা - নিয়ম
signed overflow = UBঘটে না ধরে নেওয়া - বীজগণিত
1 < 0সবসময় মিথ্যা - Dead code
return 0;যাচাই মুছে গেল - সঠিক উপায়
x == INT_MAXআগে যাচাই
int will_overflow(int x) {
return x + 1 < x; /* x+1 ছোট হলে overflow? */
}
if (will_overflow(balance)) {
reject(); /* লেনদেন বাতিল */
}
/* x হলো int (signed). */ /* signed overflow হলে প্রোগ্রাম অবৈধ। */ /* অতএব বৈধ প্রোগ্রামে x + 1 কখনো উপচায় না */ /* অর্থাৎ x + 1 সবসময় গাণিতিক x + 1 */
x + 1 < x ⇔ (দুই পাশ থেকে x বাদ) 1 < 0 ⇔ সবসময় false
int will_overflow(int x) {
return 0;
}
/* if (0) { reject(); } — পুরো অংশ বাদ */
int will_overflow(int x) {
return x == INT_MAX; /* UB ছাড়াই */
}
/* অথবা: __builtin_add_overflow(x, 1, &r) */
- প্রোগ্রামারের চিন্তা। “হার্ডওয়্যারে
INT_MAX + 1ঘুরে ঋণাত্মক হয়ে যায়। তাইx + 1যদিx-এর চেয়ে ছোট হয়, বুঝব overflow হয়েছে।” CPU-র দিক থেকে কথাটি সত্যি। কিন্তু প্রোগ্রামটি CPU-র জন্য নয়, C স্ট্যান্ডার্ডের জন্য লেখা। - Compiler-এর প্রথম ধাপ।
xsigned, আর signed overflow UB. Compiler-কে ধরে নেওয়ার অধিকার দেওয়া আছে যে বৈধ প্রোগ্রামে UB ঘটে না। তাহলেx + 1সবসময় গণিতের সাধারণ যোগফল — কখনো ঘুরে আসে না। - দ্বিতীয় ধাপ — বীজগণিত। গণিতের সাধারণ সংখ্যায়
x + 1 < xমানে1 < 0, যা কখনো সত্য নয়। এখানে compiler কোনো ভুল করছে না; আপনার দেওয়া অনুমানের ওপর নিখুঁত যুক্তি চালাচ্ছে। - তৃতীয় ধাপ — মুছে ফেলা। ফাংশনটি হয়ে গেল
return 0;, আরif-এর ভেতরেরreject()কখনো চলবে না বলে পুরোটাই বাদ।-O0-এ যাচাইটি হয়তো “কাজ করত”,-O2-এ নিঃশব্দে হারিয়ে গেল। কোনো error নেই, অনেক সময় warning-ও নেই। - সঠিক উপায়। Overflow হওয়ার আগে যাচাই করুন:
x == INT_MAX, অথবা সাধারণভাবেb > 0 && a > INT_MAX - b. GCC ও Clang-এ আছে__builtin_add_overflow, আর C23-এ এসেছে<stdckdint.h>-এরckd_add(পর্ব ৪৭). এগুলো UB ছাড়াই বলে দেয় যোগফল আঁটবে কি না।
আরও তিনটি পরিচিত ধরন
১. আগে ব্যবহার, পরে null-check. ২০০৯ সালে Linux kernel-এর tun নেটওয়ার্ক ড্রাইভারে এমন একটি কোড পাওয়া যায়, যার ধাঁচ মোটামুটি এরকম:
struct sock *sk = tun->sk; /* আগে tun ব্যবহার হলো */
if (!tun) /* তারপর null যাচাই */
return POLLERR;Compiler-এর যুক্তি: “tun->sk পড়া হয়ে গেছে। tun null হলে সেটিই UB হতো। তাই এখানে tun null নয়, আর যাচাইটি অপ্রয়োজনীয়।” GCC যাচাইটি মুছে দিল। সাধারণ অবস্থায় null ঠিকানায় পড়লে প্রোগ্রাম থেমে যায়, কিন্তু সেই সময়ের কিছু সিস্টেমে ঠিকানা ০-তে মেমরি বসানোর সুযোগ ছিল। নিরাপত্তা গবেষকেরা দেখান, এভাবে kernel-এর নিয়ন্ত্রণ নেওয়া সম্ভব। এরপর Linux kernel নিজের build-এ -fno-delete-null-pointer-checks flag যোগ করে, যাতে compiler এমন যাচাই আর না মোছে।
২. অসীম লুপের অনুমান। C11 থেকে নিয়ম বলে: যে লুপের শর্ত একটি ধ্রুবক (constant) নয়, এবং যার ভেতরে কোনো I/O, volatile বা atomic কাজ নেই, compiler ধরে নিতে পারে সেটি একসময় শেষ হবে। তাই “কিছু না করে অপেক্ষা” ধরনের লুপ optimizer পুরোপুরি মুছে দিতে পারে। while (1)-এর শর্ত ধ্রুবক, তাই সেটি এই নিয়মের বাইরে।
৩. “সময় ভ্রমণ” করা UB. সবচেয়ে অদ্ভুত ধারণাটি এটি। UB কোনো একটি লাইনে আটকে থাকে না — স্ট্যান্ডার্ডের ভাষায়, UB-ওয়ালা পুরো চলাটাই অনির্দিষ্ট। তাই প্রোগ্রাম যদি নিশ্চিতভাবে সামনে কোথাও UB-তে পৌঁছায়, compiler তার আগের কোডকেও বদলে দিতে পারে। যেমন একটি ফাংশন শেষে সবসময় একটি pointer পড়ে — optimizer চাইলে ধরে নিতে পারে pointer কখনো null নয়, এবং আগে null-এর জন্য রাখা যাচাই বা শাখাও বাদ দিতে পারে। (বাস্তবে compiler সাধারণত log লেখার মতো বাইরের কাজ সরায় না, কিন্তু UB-এর আগের হিসাব আর শাখা বদলে যাওয়ার ঘটনা দেখা যায়।) Debug করার সময় তাই “এই লাইন পর্যন্ত তো ঠিক ছিল” — এই যুক্তিও UB-এর সামনে টেকে না।
ক্রিকেটের DRS-এর কথা ভাবুন। Ball-tracking ধরে নেয় বল পদার্থবিজ্ঞানের নিয়ম মেনে চলবে, আর সেই অনুমানে বলের ভবিষ্যৎ পথ এঁকে দেয়। কেউ যদি চুপিচুপি বলের ভেতর চুম্বক বসিয়ে দেয়, যন্ত্রটি ভুল সিদ্ধান্ত দেবে — কিন্তু দোষ যন্ত্রের নয়, যে নিয়ম ভেঙেছে তার। Optimizer-ও আপনার কোড নিয়ম মানে ধরে নিয়ে “ভবিষ্যৎ” হিসাব করে।
৫ · নিরাপত্তার চোখে — মেমরির ভুল কেন সবচেয়ে বড় ঝুঁকি
এতক্ষণ UB-কে আমরা “ভুল ফল” বা “অদ্ভুত আচরণ” হিসেবে দেখেছি। কিন্তু নিরাপত্তার কাজে যাঁরা আছেন, তাঁদের চোখে প্রতিটি মেমরির ভুল একটি সম্ভাব্য vulnerability — এমন একটি দুর্বলতা, যা কাজে লাগিয়ে বাইরের কেউ প্রোগ্রামের ডেটা চুরি করতে পারে, প্রোগ্রাম বন্ধ করে দিতে পারে, বা সবচেয়ে খারাপ ক্ষেত্রে প্রোগ্রামকে নিজের ইচ্ছামতো চালাতে পারে। সাধারণ বাগ শুধু ভুল করে; নিরাপত্তার বাগকে কেউ ইচ্ছা করে খুঁজে বের করে ব্যবহার করে।
তাদের গুরুতর নিরাপত্তা-ত্রুটির প্রায় ৭০% মেমরি-নিরাপত্তার ভুল।
সংখ্যাটির উৎস খেয়াল করুন। Microsoft-এর নিরাপত্তা দল ২০১৯ সালে জানায়, বছরের পর বছর তাদের সফটওয়্যারে যে নিরাপত্তা-ত্রুটি সারানো হয়, তার প্রায় ৭০% মেমরি-নিরাপত্তার সমস্যা। ২০২০ সালে Chromium (Google Chrome-এর ভিত্তি) প্রজেক্টও তাদের উচ্চ ও গুরুতর মাত্রার নিরাপত্তা-বাগ গুনে প্রায় একই অনুপাত পায়। দুটিই বড় C ও C++ কোডবেস। এটি সব সফটওয়্যারের একটি সার্বজনীন সূত্র নয়, কিন্তু দুটি আলাদা প্রতিষ্ঠানের আলাদা হিসাব একই দিকে ইঙ্গিত করছে — এটাই চিন্তার বিষয়।
Stack-এ buffer overflow — পাশের ঘরে কী থাকে
পর্ব ১৩-তে দেখেছেন, প্রতিটি ফাংশন ডাকলে stack-এ একটি stack frame তৈরি হয়। তার ভেতরে local ভ্যারিয়েবল, আর তার কাছেই থাকে return address — ফাংশন শেষে CPU কোথায় ফিরে যাবে। ব্যবহারকারীর ডেটা আর প্রোগ্রামের নিজের হিসাবের তথ্য পাশাপাশি রাখা — সমস্যার মূল এখানেই। নিচে একটি সাধারণ x86-64 Linux সিস্টেমের সরল ছবি:
একটি ফাংশনের stack frame — সরল রূপ
Simplified stack frame layout with a stack canary (x86-64, GCC)
উঁচু ঠিকানা +-----------------------+ | return address | ফাংশন শেষে CPU কোথায় ফিরবে +-----------------------+ | saved frame pointer | আগের ফাংশনের frame-এর তথ্য +-----------------------+ | stack canary | গোপন মান; বদলে গেলে প্রোগ্রাম থামে +-----------------------+ | char name[16] | ইনপুট এখানে লেখা হয় | ^ লেখা এগোয় ওপরে | +-----------------------+ নিচু ঠিকানা
name মাত্র ১৬ বাইট। কেউ যদি gets বা যাচাইহীন strcpy দিয়ে এর চেয়ে লম্বা ইনপুট লেখে, বাড়তি বাইটগুলো থেমে থাকে না — সেগুলো পাশের ঘরগুলোর ওপর দিয়ে লেখা হয়ে যায়: অন্য local ভ্যারিয়েবল, saved frame pointer, এমনকি return address. ফাংশন শেষে CPU সেই নষ্ট হয়ে যাওয়া ঠিকানায় ফিরতে চায়। সবচেয়ে ভালো ক্ষেত্রে প্রোগ্রাম crash করে; খারাপ ক্ষেত্রে ফেরার ঠিকানা এমন কিছু হয়ে যায়, যা ইনপুট যে পাঠিয়েছে সে-ই ঠিক করে দিয়েছে। এই কারণেই একটি সাধারণ “বেশি লম্বা নাম” বাগ নিরাপত্তার ভাষায় হয়ে যায় “প্রোগ্রামের নিয়ন্ত্রণ হারানো”।
এই একটি ঝুঁকি ঠেকাতেই কয়েক দশক ধরে নানা প্রতিরক্ষা তৈরি হয়েছে: bounds checking (লেখার আগে সীমা যাচাই), stack canary (return address-এর আগে একটি গোপন মান বসিয়ে ফাংশন শেষে মিলিয়ে দেখা), NX (stack-এর মেমরিকে “চালানো যাবে না” চিহ্ন দেওয়া), আর ASLR (প্রতিবার প্রোগ্রাম খোলার সময় মেমরির ঠিকানাগুলো এলোমেলো করা)। পর্ব ১৯-এ string নিয়ে কাজ করার সময় buffer overflow আর canary-র সাথে আপনার প্রথম পরিচয় হয়েছিল; অংশ ৮-এ দেখব এরা কীভাবে একসাথে স্তর তৈরি করে।
মেমরি-নিরাপত্তার বাগের প্রধান পরিবার
| ধরন | কী ঘটে | সম্ভাব্য ক্ষতি | প্রধান প্রতিরোধ |
|---|---|---|---|
| Out-of-bounds write | buffer-এর সীমার বাইরে লেখা (stack বা heap-এ) | পাশের ডেটা নষ্ট, প্রোগ্রামের নিয়ন্ত্রণ হারানো | প্রতিটি লেখার আগে দৈর্ঘ্য যাচাই, সীমাযুক্ত ফাংশন |
| Out-of-bounds read | সীমার বাইরে থেকে পড়া — buffer over-read | গোপন তথ্য (পাসওয়ার্ড, key) ফাঁস | দৈর্ঘ্যের তথ্য বিশ্বাস করার আগে যাচাই |
| Use-after-free | free করা মেমরিতে আবার হাত দেওয়া | সেই জায়গায় এখন অন্য ডেটা; বিভ্রান্তি ও নিয়ন্ত্রণ হারানো | একটি allocation-এর একজনই মালিক, free-র পর NULL |
| Integer overflow (size হিসাবে) | n * size উপচে ছোট সংখ্যা হয়ে যায় | ছোট buffer, তারপর বড় লেখা — heap overflow | গুণ-যোগের আগে যাচাই (অংশ ৬) |
| Format string-এর অপব্যবহার | printf(user_input) | মেমরি পড়া, এমনকি লেখা (format string attack) | সবসময় printf("%s", s) (পর্ব ২৫) |
| TOCTOU race | যাচাই আর ব্যবহারের মাঝখানে অবস্থা বদলে যায় | অনুমতি নেই এমন ফাইলে লেখা | যাচাই আর ব্যবহার একই ধাপে |
শেষ সারিটি একটু আলাদা, তাই আলাদা করে বলি। TOCTOU মানে “time of check to time of use” — যাচাইয়ের সময় আর ব্যবহারের সময়ের ফাঁক। ধরুন একটি প্রোগ্রাম আগে access() দিয়ে দেখে নিল, ব্যবহারকারী /tmp/report.txt ফাইলে লিখতে পারেন কি না। তারপর কয়েক মাইক্রোসেকেন্ড পরে fopen() দিয়ে ফাইলটি খুলল। এই ফাঁকে অন্য কেউ যদি ঐ নামের জায়গায় একটি গুরুত্বপূর্ণ সিস্টেম-ফাইলের link বসিয়ে দেয়, তাহলে যাচাই হয়েছিল এক ফাইলের, লেখা হচ্ছে আরেক ফাইলে। এটি মেমরির বাগ নয়, কিন্তু একই মানসিকতার ভুল — “একটু আগে যা সত্য ছিল, এখনো সত্য” ধরে নেওয়া। সমাধানও একই ধাঁচের: যাচাই আর কাজ আলাদা ধাপে না করে একসাথে করা — যেমন সরাসরি খোলার চেষ্টা করে ফল দেখা, আর খোলা ফাইলের ওপরেই (নামের ওপর নয়) পরের যাচাইগুলো করা।
১৯৮৮ — Morris worm. নভেম্বর ১৯৮৮-তে Robert Tappan Morris নামের এক ছাত্রের লেখা একটি প্রোগ্রাম নিজে নিজে তখনকার ইন্টারনেটে ছড়িয়ে পড়ে, আর বহু বিশ্ববিদ্যালয় ও গবেষণাগারের কম্পিউটার প্রায় অচল করে দেয়। এটি ছড়ানোর কয়েকটি পথের একটি ছিল fingerd নামের একটি সার্ভিস, যা ইনপুট পড়তে gets() ব্যবহার করত — যে ফাংশন buffer-এর আকারই জানে না। এটিকে প্রায়ই ইন্টারনেটের প্রথম ব্যাপক আলোচিত worm বলা হয়, আর এর পরেই বড় পরিসরে কম্পিউটার নিরাপত্তা নিয়ে সংগঠিত কাজ শুরু হয়। C11 স্ট্যান্ডার্ড থেকে gets পুরোপুরি বাদ দেওয়া হয়েছে।
২০১৪ — Heartbleed. OpenSSL — যে library দিয়ে ইন্টারনেটের বিশাল অংশ HTTPS সংযোগ এনক্রিপ্ট করে — তার “heartbeat” অংশে একটি bounds check বাদ পড়েছিল। অন্য পক্ষ “আমাকে এত বাইট ফেরত পাঠাও” বললে কোডটি সংখ্যাটি যাচাই না করেই বিশ্বাস করত, আর buffer-এর পরের মেমরিও পাঠিয়ে দিত। ফলে সার্ভারের মেমরি থেকে গোপন তথ্য ফাঁস হওয়ার ঝুঁকি তৈরি হয়। এটি ছিল একটি out-of-bounds read — কিছু লেখা হয়নি, তাই stack canary বা NX-এর মতো প্রতিরক্ষাও এটি থামাতে পারেনি। বিশ্বজুড়ে বহু সার্ভারে তাড়াহুড়া করে আপডেট আর নতুন সার্টিফিকেট বসাতে হয়েছিল।
৬ · Integer overflow — হিসাবের ভুল থেকে মেমরির ভুল
অংশ ৩-এ বলেছিলাম, unsigned সংখ্যার wraparound সম্পূর্ণ বৈধ, UB নয়। এখন দেখবেন, বৈধ হলেই নিরাপদ নয়। সবচেয়ে বিপজ্জনক জায়গা হলো মেমরির আকার হিসাব করা — malloc(count * size). count যদি বাইরে থেকে আসে (একটি ফাইলের header, network packet, ব্যবহারকারীর ইনপুট), তাহলে গুণফল উপচে পড়তে পারে। পুরোনো ৩২-বিট কম্পিউটার ও ফোন, আর অনেক microcontroller-এ size_t এখনো ৩২-বিট — সর্বোচ্চ মান 4294967295. নিচের ডায়াগ্রামে ঠিক সেই অবস্থা দেখুন:
1size_t count = read_u32(pkt); /* from file or network */2size_t bytes = count * sizeof(int);3int *a = malloc(bytes);4for (size_t i = 0; i < count; i++)5 a[i] = 0;6/* fix: if (count > SIZE_MAX / sizeof(int)) fail */
| count | |
| গণিতে গুণফল | |
| bytes | |
| a | |
| লেখা |
- বাইরে থেকে একটি সংখ্যা এলো। একটি ছবির ফাইল বা network packet-এর header-এ লেখা: “এখানে
1073741825টি সংখ্যা আছে।” প্রোগ্রাম সংখ্যাটি পড়ল। এটি কে লিখেছে, প্রোগ্রাম জানে না — হতে পারে ভুল, হতে পারে ইচ্ছাকৃত। - গুণ — গণিতের ফল। প্রতিটি
int৪ বাইট, তাই দরকার1073741825 × 4 = 4294967300বাইট। কিন্তু ৩২-বিটsize_t-এর সর্বোচ্চ মান4294967295— ফলটি জায়গায় আঁটছে না। - Wraparound — নিঃশব্দে, বৈধভাবে। unsigned গুণফল ২৩২ (
4294967296) দিয়ে ভাগশেষ নেয়:4294967300 − 4294967296 = 4. তাইbytesহলো মাত্র4. কোনো UB নেই, কোনো warning নেই, কোনো error নেই — স্ট্যান্ডার্ড মেনেই ভুল সংখ্যা। malloc(4)সফল! ৪ বাইট চাওয়া খুবই স্বাভাবিক, তাইmallocএকটি বৈধ pointer ফেরত দেয়। NULL-check থাকলেও এখানে কিছু ধরা পড়ে না — কারণ বরাদ্দ সত্যিই সফল হয়েছে।- লুপ চলে
countপর্যন্ত। লুপের হিসাবে এখনো প্রায় একশো সাত কোটি ঘর।a[0]ঠিক আছে, কিন্তুa[1]থেকেই লেখা চলে যাচ্ছে বরাদ্দের বাইরে — heap-এ পাশের ব্লকগুলোর ওপর। এটিই heap overflow, আর এখান থেকে প্রোগ্রামের আচরণ সত্যিই undefined. বাস্তবে এই ধাঁচের বাগ ছবি, ভিডিও আর ফাইল-ফরম্যাট পড়ার library-তে বহুবার পাওয়া গেছে। - সমাধান — গুণের আগে ভাগ দিয়ে যাচাই।
count > SIZE_MAX / sizeof(int)হলে গুণফল উপচাবে। এখানেSIZE_MAX / 4 = 1073741823, আর count তার চেয়ে বড় — তাই গুণ করার আগেই অনুরোধ বাতিল। ভাগে কখনো overflow হয় না, তাই এই যাচাই নিজে নিরাপদ।
এই ছকটি — “বাইরে থেকে আসা সংখ্যা → আকার হিসাব → ছোট buffer → বড় লেখা” — এতই সাধারণ যে নিরাপত্তা-বিশেষজ্ঞরা কোড পড়ার সময় প্রথমেই malloc-এর ভেতরের প্রতিটি গুণ-যোগ খোঁজেন। এখন হাতে-কলমে দেখুন কীভাবে আকার আর টাকার হিসাব দুটোই নিরাপদে করা যায়। GCC আর Clang দুটোতেই compiler builtin হিসেবে __builtin_mul_overflow আর __builtin_add_overflow আছে — এরা হিসাবটি করে, আর ফল জায়গায় না আঁটলে true ফেরত দেয়। একে বলে checked arithmetic.
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
static void *alloc_array(size_t count, size_t size) {
size_t total;
if (__builtin_mul_overflow(count, size, &total)) { /* আগে যাচাই */
printf("rejected: %zu * %zu overflows\n", count, size);
return NULL;
}
printf("allocating %zu bytes\n", total);
return malloc(total);
}
int main(void) {
free(alloc_array(1000, sizeof(int)));
free(alloc_array(SIZE_MAX / 2, sizeof(int)));
int64_t balance = INT64_MAX - 100, sum; /* পয়সায় হিসাব */
if (__builtin_add_overflow(balance, 500, &sum)) printf("deposit rejected\n");
else printf("new balance %lld\n", (long long)sum);
return 0;
}প্রথম ডাকে 1000 × 4 = 4000 — সহজেই আঁটে, তাই আউটপুট allocating 4000 bytes. দ্বিতীয় ডাকে ৬৪-বিট সিস্টেমেও SIZE_MAX / 2-কে ৪ দিয়ে গুণ করলে উপচে যায়, তাই malloc ডাকাই হয় না: rejected: 9223372036854775807 * 4 overflows. শেষ অংশে একটি wallet-এর ব্যালান্স সর্বোচ্চ সীমার ১০০ পয়সা নিচে; ৫০০ যোগ করলে signed overflow হতো, যা UB. builtin সেটি আগেই ধরে deposit rejected ছাপে। free(NULL) বৈধ এবং কিছুই করে না, তাই দুই ক্ষেত্রেই free নিরাপদ। Builtin না থাকলে হাতে লেখা যাচাই: গুণের জন্য count > SIZE_MAX / size, যোগের জন্য b > 0 && a > INT64_MAX - b. C23-এ এর প্রমিত রূপ এসেছে — অংশ ৯ দেখুন।
size_t আর signed সংখ্যার মিশ্রণ
আরেকটি নীরব ফাঁদ হলো signed আর unsigned মেশানো। ধরুন একটি ফাংশন দৈর্ঘ্য নেয় int len হিসেবে, আর ভেতরে ডাকে memcpy(dst, src, len). কেউ len = -1 পাঠালে memcpy-এর size_t parameter-এ সেটি রূপান্তরিত হয়ে যায় সবচেয়ে বড় সংখ্যায় — SIZE_MAX. আবার if (len < sizeof buf) যাচাইয়েও পর্ব ০৮-এর usual arithmetic conversions মেনে -1 unsigned হয়ে বিশাল সংখ্যা হয়, অথবা উল্টো দিকে কোনো যাচাই ঋণাত্মক সংখ্যাকে “ছোট, তাই ঠিক আছে” ভেবে পার করে দেয়। নিয়মটি সহজ: আকার, দৈর্ঘ্য আর index-এর জন্য সবসময় size_t, আর বাইরে থেকে আসা signed সংখ্যাকে আগে ঋণাত্মক কি না দেখে, তারপর রূপান্তর করুন। -Wsign-compare আর -Wsign-conversion warning এমন অনেক জায়গা দেখিয়ে দেয়।
calloc(count, size) নিজেই এই গুণটি করে। glibc ও musl-সহ আধুনিক C library গুণফল উপচালে NULL ফেরত দেয়, তাই array বরাদ্দে malloc(count * size)-এর চেয়ে calloc নিরাপদ পছন্দ। realloc-এর জন্য একই সুবিধা দেয় reallocarray — এটি প্রথমে OpenBSD-তে আসে, এখন glibc-তেও আছে, তবে এটি স্ট্যান্ডার্ড C-এর অংশ নয়।
৭ · নিরাপদ কোডের অভ্যাস — প্রথম দেয়াল আপনি নিজে
পরের অংশে compiler, sanitizer আর OS-এর প্রতিরক্ষা দেখব। কিন্তু সেগুলো সবই দ্বিতীয় সারির খেলোয়াড়। প্রথম দেয়াল হলো কোড লেখার অভ্যাস। ক্রিকেটে উইকেটকিপার আর স্লিপ ফিল্ডার থাকলেও ব্যাটসম্যান নিজে বাজে শট খেলেন না — এখানেও তেমন। নিচের অভ্যাসগুলো একবার রপ্ত করলে উপরের তালিকার বেশিরভাগ বাগ জন্মই নেয় না।
| অভ্যাস | এড়িয়ে চলুন | ব্যবহার করুন |
|---|---|---|
| সীমাযুক্ত ফাংশন | gets, strcpy, strcat, sprintf | fgets, snprintf, আকার জানা থাকলে memcpy, দৈর্ঘ্য মাপতে strnlen |
| প্রতিটি আকার-হিসাব যাচাই | malloc(n * size), len + 1 অন্ধভাবে | গুণ-যোগের আগে সীমা যাচাই, calloc, checked arithmetic |
| সঠিক টাইপ | দৈর্ঘ্যের জন্য int | size_t; signed থেকে আনার আগে ঋণাত্মক কি না দেখা |
| সব কিছু শুরুর মান দিয়ে | int x;, malloc-এর পর সরাসরি পড়া | int x = 0;, struct s v = {0};, দরকারে calloc |
| প্রতিটি বরাদ্দের একজন মালিক | কে free করবে অস্পষ্ট, একাধিক জায়গায় free | একটি ফাংশন বা struct-ই মালিক (ownership); free-র পর p = NULL; |
| বাইরের সব ইনপুট সন্দেহজনক | ফাইল বা packet-এর দৈর্ঘ্য-ঘর বিশ্বাস করা | input validation: দৈর্ঘ্য, পরিসর, ফরম্যাট — সব আগে যাচাই |
| ন্যূনতম অনুমতি | সব প্রোগ্রাম root বা Administrator হিসেবে চালানো | least privilege: যতটুকু দরকার, ততটুকুই অনুমতি |
শেষ অভ্যাসটি কোডের বাইরের মনে হলেও আসলে খুব গুরুত্বপূর্ণ। কোনো বাগ যদি থেকেও যায়, ক্ষতি কতটা হবে তা নির্ভর করে প্রোগ্রামটির হাতে কতটা ক্ষমতা ছিল তার ওপর। একটি ছবি ছোট করার প্রোগ্রামের ব্যাংকের ডেটাবেস ছোঁয়ার অনুমতি থাকার কোনো কারণ নেই। পর্ব ৪৮-এ process-এর অনুমতি আর বিচ্ছিন্নতা নিয়ে আরও দেখবেন।
snprintf — শুধু ব্যবহার নয়, ফেরত মানটিও পড়ুন
অনেকে sprintf-এর জায়গায় snprintf বসিয়েই নিশ্চিন্ত হয়ে যান। হ্যাঁ, snprintf কখনো buffer-এর সীমা পেরিয়ে লেখে না। কিন্তু লেখা কেটে ছোট হয়ে গেলে (truncation) সেটিও একটি বাগ হতে পারে — অর্ধেক ফাইলের নাম, অর্ধেক SMS, অর্ধেক SQL query. snprintf-এর ফেরত মান বলে দেয়, পুরো লেখাটির জন্য কত অক্ষর লাগত। সেটি দেখেই truncation ধরা যায়:
1char sms[16];2int n = snprintf(sms, sizeof sms, "Hi %s", name);3if (n < 0 || (size_t)n >= sizeof sms) {4 /* truncated: reject, or grow */5}6char *full = malloc((size_t)n + 1);7snprintf(full, (size_t)n + 1, "Hi %s", name);
| name | |
| n | |
| sms | |
| full |
- ছোট নাম।
"Hi Rahim"মোট ৮ অক্ষর।snprintfসেটি লিখে শেষে\0বসায় আর ফেরত দেয়8. ৮ < ১৬, অর্থাৎ পুরোটা এঁটেছে। - লম্বা নাম।
"Hi Mohammad Abdullah"লাগবে ২০ অক্ষর, আর\0-সহ ২১ বাইট।snprintfসীমা মানে: ১৫টি অক্ষর আর একটি\0লিখে থেমে যায় — buffer নিরাপদ। কিন্তু ফেরত দেয়20, অর্থাৎ যত অক্ষর লেখা উচিত ছিল। - যাচাই।
n >= sizeof smsমানে লেখা কাটা পড়েছে।n < 0মানে encoding-এর মতো কোনো error. এই এক লাইনের যাচাই না থাকলে প্রোগ্রাম চুপচাপ ভুল নাম নিয়ে কাজ চালিয়ে যেত — হয়তো ভুল মানুষের কাছে SMS, বা ভুল ফাইলে লেখা। - তুলনা:
sprintfহলে কী হতো?sprintfসীমা জানে না; সে পুরো ২১ বাইটই লিখত, অর্থাৎ ১৬ ঘরের বাইরে ৫ বাইট — অংশ ৫-এর stack overflow. আরstrncpyদিয়ে কপি করলে লেখা কাটা পড়লেও শেষে\0বসানোর নিশ্চয়তা নেই — আরেক রকম ফাঁদ। - সমাধান বেছে নিন। কাজের ধরন বুঝে: অনুরোধ বাতিল করুন, ব্যবহারকারীকে জানান, অথবা ফেরত মান থেকেই সঠিক আকার জেনে
n + 1বাইট বরাদ্দ করে আবার লিখুন। এখানেnছোট ও যাচাই করা সংখ্যা, তাইn + 1উপচানোর ভয় নেই।
এবার একটি পূর্ণ, চালানো যায় এমন উদাহরণ। ফাংশনটি একটি bKash-ধাঁচের লেনদেনের SMS তৈরি করে, আর লেখা না আঁটলে সেটি স্পষ্টভাবে জানায়:
#include <stdio.h>
#include <string.h>
/* 0 = ok, -1 = না আঁটলে (out-এ তবুও \0-সহ নিরাপদ অংশ থাকে) */
static int format_sms(char *out, size_t cap, const char *name, long taka) {
int n = snprintf(out, cap, "Dear %s, you received %ld Tk.", name, taka);
if (n < 0) return -1; /* encoding error */
if ((size_t)n >= cap) { /* কাটা পড়েছে */
printf("needed %d bytes, have %zu\n", n + 1, cap);
return -1;
}
return 0;
}
int main(void) {
char sms[40];
const char *names[] = { "Rahim", "Mohammad Abdullah Al Mamun" };
for (int i = 0; i < 2; i++) {
int rc = format_sms(sms, sizeof sms, names[i], 500);
printf("%s: [%s] len=%zu\n", rc == 0 ? "ok" : "TRUNCATED", sms, strnlen(sms, sizeof sms));
}
return 0;
}প্রথম নামে বার্তাটি ৩২ অক্ষরের, ৪০ বাইটের buffer-এ সহজেই আঁটে: ok: [Dear Rahim, you received 500 Tk.] len=32. দ্বিতীয় নামে লাগত ৫৩ অক্ষর, তাই আউটপুট needed 54 bytes, have 40, তারপর TRUNCATED: [Dear Mohammad Abdullah Al Mamun, you re] len=39. লক্ষ করুন, কাটা পড়ার পরও buffer-এ ৩৯ অক্ষর আর একটি \0 — কোনো overflow নেই, string-টিও ঠিকভাবে শেষ হয়েছে। কিন্তু “you re…” লেখা SMS পাঠানো নিশ্চয়ই ঠিক নয় — তাই ফাংশনটি -1 ফেরত দিয়ে সিদ্ধান্ত ডাকা অংশের হাতে দিয়েছে। strnlen সর্বোচ্চ sizeof sms পর্যন্ত গোনে, তাই \0 হারিয়ে গেলেও এটি buffer-এর বাইরে পড়তে যায় না (এটি POSIX-এর ফাংশন, স্ট্যান্ডার্ড C-এর নয়, তবে Linux, macOS-সহ প্রায় সব সিস্টেমে পাওয়া যায়)।
৮ · প্রতিরক্ষার স্তর — একটি দেয়ালে ভরসা নয়
নিরাপত্তার জগতে একটি পুরোনো নীতি আছে: defence in depth — স্তরে স্তরে প্রতিরক্ষা। ঢাকার কোনো অফিস ভবনের কথা ভাবুন: গেটে দারোয়ান, লিফটের সামনে কার্ড, প্রতিটি ফ্লোরে CCTV, গুরুত্বপূর্ণ ঘরে আলাদা তালা। কোনো একটি স্তর ব্যর্থ হলেও পরের স্তর আছে। C কোডের নিরাপত্তাও এভাবে সাজানো হয়। একটি বাগ কোন স্তরে কীভাবে ধরা পড়ে, ধাপে ধাপে দেখুন:
- বাগ
strcpy(buf, in)সীমা যাচাই নেই - Compiler
-Wall -Wextraকিছু ধরে - টেস্ট
ASan + fuzzingচালিয়ে ধরে - Production
canary, NX, ASLRক্ষতি কমায় - রিভিউ
fix + testফিরে আসা ঠেকায়
void save_name(const char *in) {
char buf[32];
strcpy(buf, in); /* in কত লম্বা? কেউ জানে না */
store(buf);
}
/* ইনপুটের দৈর্ঘ্য compile-এর সময় জানা থাকলে: */ warning: 'strcpy' writing 41 bytes into a region of size 32 overflows the destination [-Wstringop-overflow=] /* কিন্তু in যদি network থেকে আসে, compiler কিছুই জানে না */ -- কোনো warning নেই --
/* fuzzer লক্ষ লক্ষ এলোমেলো ইনপুট পাঠায়, একটিতে ৪০ অক্ষর */ ==4242==ERROR: AddressSanitizer: stack-buffer-overflow WRITE of size 41 ... #0 in strcpy #1 in save_name order.c:3 /* ঠিক কোন লাইন, কত বাইট — release-এর আগেই ধরা */
/* -D_FORTIFY_SOURCE=2: strcpy-এর জায়গায় যাচাইসহ সংস্করণ */ *** buffer overflow detected ***: terminated /* অথবা -fstack-protector-strong: canary বদলে গেছে */ *** stack smashing detected ***: terminated ফল: নিয়ন্ত্রণ হারানোর বদলে নিয়ন্ত্রিত crash
int save_name(const char *in) {
char buf[32];
int n = snprintf(buf, sizeof buf, "%s", in);
if (n < 0 || (size_t)n >= sizeof buf) return -1;
store(buf);
return 0;
}
/* + ৪০ বাইটের ইনপুট দিয়ে regression test, CI-তে fuzzing */
- বাগ জন্ম নিল।
inকত লম্বা,save_nameজানে না, তবুও ৩২ বাইটের buffer-এ পুরোটা কপি করছে। ৩১ অক্ষর পর্যন্ত সব ঠিক; ৩২ বা বেশি হলেই সীমার বাইরে লেখা। - স্তর ১ — compiler warning. ইনপুট যদি compile-এর সময় জানা একটি লম্বা literal হয়, GCC
-Wstringop-overflowদিয়ে সাবধান করে,-Werrorথাকলে build-ই থামে। কিন্তু আসল ইনপুট আসে চালানোর সময় — ফাইল বা network থেকে। Compiler তখন অন্ধ। তাই এই স্তর সস্তা ও দ্রুত, কিন্তু অসম্পূর্ণ। - স্তর ২ — sanitizer ও fuzzing. টেস্ট build-এ sanitizer (পর্ব ৩১) প্রতিটি মেমরি-প্রবেশ পাহারা দেয়। আর fuzzing (পর্ব ৩২) যন্ত্র দিয়ে অসংখ্য অদ্ভুত ইনপুট বানিয়ে প্রোগ্রামে পাঠায় — মানুষ যা ভাবেনি, সেটিও। দুটো একসাথে হলে বাগটি ঠিক লাইন নম্বরসহ ধরা পড়ে, ব্যবহারকারীর কাছে পৌঁছানোর আগেই।
- স্তর ৩ — চলার সময়ের সুরক্ষা। বাগ যদি সব টেস্ট পেরিয়ে release-এ পৌঁছায়, তখন hardening flag আর OS-এর সুরক্ষা কাজ করে।
_FORTIFY_SOURCEযাচাই করা যায় এমন জায়গায় buffer-এর আকার মিলিয়ে দেখে; canary নষ্ট হলে প্রোগ্রাম নিজেই থামে; NX আর ASLR আক্রমণ কঠিন করে। ফল: একটি crash — খারাপ, কিন্তু নিয়ন্ত্রণ হারানোর চেয়ে অনেক ভালো। - স্তর ৪ — রিভিউ ও প্রক্রিয়া। Code review-এ বাগটির মূল কারণ সারানো হলো, আর ঠিক সেই ইনপুট দিয়ে একটি regression test যোগ হলো। CI-তে নিয়মিত fuzzing চললে একই ধরনের বাগ আবার ঢুকলে সাথে সাথে ধরা পড়বে। শুধু এই স্তরেই বাগটি সত্যিকারের নির্মূল হয়; বাকিগুলো ধরে বা থামায় মাত্র।
Compiler flag — বিনা খরচের বীমা
GCC বা Clang-এ সাধারণ একটি নিরাপদ build দেখতে মোটামুটি এরকম। টেস্টের জন্য আর release-এর জন্য দুটি আলাদা কমান্ড রাখা ভালো অভ্যাস:
# টেস্ট ও ডেভেলপমেন্ট: বাগ খোঁজা
gcc -std=c17 -Wall -Wextra -Werror -g -O1 \
-fsanitize=address,undefined -fno-omit-frame-pointer app.c -o app_test
# release: hardening
gcc -std=c17 -Wall -Wextra -Werror -O2 -D_FORTIFY_SOURCE=2 \
-fstack-protector-strong -fPIE -pie app.c -o app| Flag | কী করে | মনে রাখুন |
|---|---|---|
-Wall -Wextra | সন্দেহজনক কোডে warning: uninitialised ভ্যারিয়েবল, signed-unsigned তুলনা, ভুল format specifier… | নাম “all” হলেও সব warning নয় |
-Werror | প্রতিটি warning-কে error বানায় — warning উপেক্ষা করা যায় না | নতুন compiler নতুন warning আনলে build থামতে পারে; দলে আলোচনা করে চালু করুন |
-D_FORTIFY_SOURCE=2 | strcpy, memcpy, sprintf ইত্যাদির জায়গায় আকার-যাচাইসহ সংস্করণ বসায় (FORTIFY_SOURCE) | শুধু optimization (-O1 বা বেশি, সাধারণত -O2) চালু থাকলে কাজ করে; নতুন GCC ও glibc-তে আরও কড়া মাত্রা =3 |
-fstack-protector-strong | array বা ঠিকানা নেওয়া local ভ্যারিয়েবল আছে এমন ফাংশনে stack canary বসায় | অনেক Linux distribution-এর GCC-তে বা প্যাকেজ build-এ এখন ডিফল্ট |
-fPIE -pie | প্রোগ্রামটিকে যেকোনো ঠিকানায় বসানো যায় এমন করে তৈরি করে (PIE), যাতে ASLR প্রোগ্রামের নিজের কোডকেও এলোমেলো জায়গায় বসাতে পারে | অনেক আধুনিক Linux distribution-এর GCC-তে ডিফল্ট |
-fsanitize=address,undefined | ASan ও UBSan: চালানোর সময় মেমরি-ভুল ও UB ধরে | শুধু টেস্টে; প্রোগ্রাম ধীর হয় ও মেমরি বেশি লাগে |
OS-এর সুরক্ষা — কেন ঝুঁকি কমায়, শূন্য করে না
আজকের Linux, Windows, macOS, Android — সবাই কয়েকটি mitigation একসাথে ব্যবহার করে। NX (Windows-এ নাম DEP) ডেটা রাখার মেমরিকে “এখানে কোড চালানো যাবে না” চিহ্ন দেয়, তাই stack-এ ঢোকানো বাইট কোড হিসেবে চলে না। ASLR প্রতিবার প্রোগ্রাম খোলার সময় stack, heap, library-র ঠিকানা এলোমেলো করে, তাই আক্রমণকারী জানে না কোথায় কী আছে। Canary return address-এর আগের পাহারাদার।
কিন্তু এদের প্রতিটিই কোনো একটি নির্দিষ্ট পথ বন্ধ করে, বাগটি নয়। Canary শুধু return address-এর আশেপাশের লেখা ধরে; পাশের অন্য ভ্যারিয়েবল বা heap-এর ডেটা নষ্ট হলে সে কিছুই জানে না। ASLR-এর এলোমেলো ঠিকানা কোনো তথ্য ফাঁসের বাগে বেরিয়ে গেলে তার সুরক্ষাও দুর্বল হয়ে যায় — আর ৩২-বিট সিস্টেমে এলোমেলো করার জায়গাই কম। NX কোড ঢোকানো ঠেকায়, কিন্তু প্রোগ্রামের ভেতরে আগে থেকে থাকা কোডের অপব্যবহার পুরোপুরি ঠেকাতে পারে না; এজন্যই নতুন CPU-তে shadow stack আর control-flow integrity-র মতো আরও স্তর আসছে। আর Heartbleed-এর মতো out-of-bounds read-এ এদের কোনোটিই কাজে আসেনি। তাই এগুলোকে ভাবুন সিটবেল্টের মতো — দুর্ঘটনার ক্ষতি কমায়, কিন্তু সিটবেল্ট আছে বলে বেপরোয়া গাড়ি চালানোর লাইসেন্স দেয় না।
Coding standard — অন্যের ভুল থেকে শেখা নিয়ম
SEI CERT C
যুক্তরাষ্ট্রের Carnegie Mellon বিশ্ববিদ্যালয়ের Software Engineering Institute-এর তৈরি CERT C coding standard. প্রতিটি নিয়মের সাথে ভুল কোড, ঠিক কোড আর ঝুঁকির মাত্রা দেওয়া থাকে — বিনামূল্যে অনলাইনে পড়া যায়। সাধারণ, নিরাপত্তা-সংবেদনশীল সফটওয়্যারের জন্য চমৎকার শুরু।
নিরাপত্তা-কেন্দ্রিকMISRA C
গাড়ি শিল্পের একটি সংগঠন থেকে শুরু হওয়া MISRA C নিয়মাবলি, এখন গাড়ি, বিমান, চিকিৎসা-যন্ত্রসহ নানা embedded সিস্টেমে ব্যবহৃত। এটি C-এর একটি নিরাপদ উপসেট বেঁধে দেয় — যেমন dynamic memory প্রায় নিষেধ। পর্ব ৫১-এ embedded C-এর সাথে আবার দেখবেন।
নিরাপত্তা-জরুরি যন্ত্রStatic analysis
কোড না চালিয়েই বাগ খোঁজার টুল — যেমন GCC-র -fanalyzer, Clang Static Analyzer, cppcheck. এরা coding standard-এর অনেক নিয়ম যন্ত্র দিয়ে যাচাই করে। Sanitizer চালানো পথটুকু দেখে; static analysis সব পথ দেখতে চেষ্টা করে, তবে মাঝে মাঝে ভুল সতর্কতাও দেয়।
৯ · মেমরি-নিরাপত্তার বিতর্ক — C কি বিদায় নিচ্ছে?
“প্রায় ৭০%” সংখ্যাটি প্রকাশের পর সফটওয়্যার শিল্পে একটি বড় প্রশ্ন উঠেছে: নতুন কোড কি আদৌ C-তে লেখা উচিত? এখানে দুই পক্ষের কথাই শোনা দরকার।
পরিবর্তনের দিক
- Rust আর বড় প্রজেক্ট: Rust একটি memory-safe language — compiler নিজেই ownership আর borrowing-এর নিয়ম দিয়ে use-after-free, data race আর সীমার বাইরে প্রবেশ ঠেকায়, আর সেটি garbage collector ছাড়াই। Linux kernel ২০২২ সালের শেষে (সংস্করণ ৬.১) প্রথমবার Rust কোডের প্রাথমিক সমর্থন যোগ করে; এরপর ধীরে ধীরে Rust-এ লেখা driver আসতে শুরু করেছে। Google Android-এ নতুন সিস্টেম-কোডের একটি অংশ Rust-এ লিখছে, আর জানিয়েছে যে এরপর Android-এ মেমরি-নিরাপত্তার ত্রুটির অনুপাত কয়েক বছরে উল্লেখযোগ্যভাবে কমেছে।
- সরকারি নির্দেশনা: ২০২২ থেকে ২০২৪ সালের মধ্যে যুক্তরাষ্ট্রের NSA, CISA ও অন্যান্য সরকারি সংস্থা একাধিক নির্দেশিকা প্রকাশ করে, যেখানে সফটওয়্যার নির্মাতাদের memory-safe ভাষার দিকে যাওয়ার পরিকল্পনা করতে উৎসাহ দেওয়া হয়। অন্য কয়েকটি দেশের নিরাপত্তা সংস্থাও এর কিছু নির্দেশিকায় যুক্ত হয়।
C কেন থেকে যাচ্ছে
- বিদ্যমান কোড: OS kernel, database, network stack, ভাষার interpreter — কয়েক বিলিয়ন লাইনের পরীক্ষিত C কোড চলছে। সব নতুন করে লেখা ঝুঁকিপূর্ণ ও ব্যয়বহুল; নতুন করে লেখা কোডেও নতুন বাগ আসে।
- Embedded জগৎ: অনেক ছোট microcontroller-এর জন্য ভালো compiler, টুল আর নিরাপত্তা-সার্টিফিকেশন মূলত C-তেই আছে।
- গতি ও নিয়ন্ত্রণ: কোথায় কোন বাইট, কখন বরাদ্দ — C-তে সবকিছু প্রোগ্রামারের হাতে।
- সার্বজনীন সংযোগ: প্রায় সব ভাষা অন্য ভাষার সাথে কথা বলে C-এর ABI দিয়ে। Rust, Python, Go — সবাই C ফাংশন ডাকতে পারে, C-এর মতো ফাংশন প্রকাশ করতে পারে।
বাস্তব চিত্রটি তাই “C না Rust” নয়, বরং “দুটোই, দায়িত্বের সাথে”। নতুন, ইনপুট-নির্ভর ও ঝুঁকিপূর্ণ অংশ ধীরে ধীরে memory-safe ভাষায় যাচ্ছে; বিদ্যমান C কোডে চলছে কড়া flag, sanitizer, fuzzing আর coding standard. একজন ভালো C প্রোগ্রামারের কাজ এই পর্বের অভ্যাসগুলোকে স্বভাবে পরিণত করা — তাহলে আপনি যে ভাষাতেই যান, মেমরি নিয়ে আপনার চিন্তা পরিষ্কার থাকবে।
১. Two's complement এখন বাধ্যতামূলক। C23 থেকে signed integer-কে অবশ্যই two's complement পদ্ধতিতে রাখতে হবে (পর্ব ০৫)। কিন্তু সাবধান — signed overflow এখনো UB. সংখ্যা কীভাবে রাখা হয় সেটি নির্দিষ্ট হয়েছে, কিন্তু optimizer-এর অংশ ৪-এর অনুমানগুলো রাখতে overflow-কে ইচ্ছা করেই undefined রাখা হয়েছে।
২. <stdckdint.h> — প্রমিত checked arithmetic. C23-এ এসেছে ckd_add, ckd_sub আর ckd_mul. যেমন bool bad = ckd_mul(&total, count, size); — গাণিতিক ফল total-এর টাইপে না আঁটলে true ফেরত দেয় (তখন total-এ থাকে ঘুরে আসা মান)। এগুলো GCC-র builtin-এর প্রমিত রূপ, তাই নতুন compiler-এ portable কোড লেখা যায়। বিস্তারিত পর্ব ৪৭-এ।
পরীক্ষা ও ইন্টারভিউতে — বিশ্ববিদ্যালয়, চাকরি বা কোডিং ইন্টারভিউর পরীক্ষায় এই ধরনের প্রশ্ন আসতে পারে:
- Undefined, unspecified আর implementation-defined behavior-এর পার্থক্য? Implementation-defined: compiler বেছে নেয় ও লিখে জানায়; unspecified: কয়েকটি বৈধ ফলের যেকোনোটি; undefined: কোনো শর্তই নেই।
gets()কেন ব্যবহার করা যায় না? সে buffer-এর আকার জানে না, তাই যেকোনো লম্বা ইনপুটে overflow; C11 থেকে স্ট্যান্ডার্ড থেকে বাদ, বদলেfgets.- Buffer overflow কী, আর কীভাবে ঠেকাবেন? buffer-এর সীমার বাইরে লেখা; সীমাযুক্ত ফাংশন ও দৈর্ঘ্য যাচাই, সাথে canary, NX, ASLR.
unsignedoverflow আরintoverflow-এর পার্থক্য? unsigned নিয়ম মেনে ঘুরে আসে (বৈধ); signed overflow UB.strncpyকি নিরাপদ বিকল্প? পুরোপুরি নয় — লেখা কাটা পড়লে শেষে\0বসায় না;snprintfসাথে ফেরত-মান যাচাই ভালো।
১০ · AI-এর যুগে — AI-এর লেখা C কোডের নিরাপত্তা-যাচাই
AI দিয়ে লেখা C কোড সাধারণত compile হয়, সাধারণ ইনপুটে ঠিক চলে, দেখতেও পরিপাটি। সমস্যা হলো, AI শিখেছে ইন্টারনেটের বিশাল পরিমাণ পুরোনো কোড থেকে — যার অনেকটাই strcpy, sprintf, যাচাইহীন malloc দিয়ে লেখা। ফলে AI-এর কোডে প্রায়ই সূক্ষ্ম মেমরি-নিরাপত্তা ও UB-এর সমস্যা থাকে, যা সাধারণ টেস্টে ধরা পড়ে না। নিরাপত্তার বাগ ধরা পড়ে অস্বাভাবিক ইনপুটে — আর সেটি পরীক্ষা করার দায়িত্ব আপনার।
লম্বা, ফাঁকা, ঋণাত্মক ও বিশাল ইনপুটেও “ঠিক থাকে” — এটি প্রমাণ।
AI-এর কোডে যেসব ভুল প্রায়ই দেখা যায়
| ভুল | কেন বিপজ্জনক | যা হওয়া উচিত |
|---|---|---|
strcpy / sprintf দিয়ে নির্দিষ্ট আকারের buffer-এ লেখা | লম্বা ইনপুটে overflow | snprintf সাথে ফেরত মান যাচাই |
malloc(n * sizeof(T)), n বাইরে থেকে আসা | গুণফল উপচে ছোট buffer | calloc বা গুণের আগে সীমা যাচাই |
overflow ধরতে if (a + b < a) (signed) | নিজেই UB; optimizer মুছে দিতে পারে | যোগের আগে যাচাই বা __builtin_add_overflow / ckd_add |
দৈর্ঘ্যের জন্য int, তুলনায় sizeof | ঋণাত্মক মান unsigned হয়ে বিশাল সংখ্যা | size_t; বাইরে থেকে আসা মান আগে যাচাই |
error পথে free দুবার, বা free-র পর pointer ব্যবহার | double free, use-after-free | একজন মালিক, goto cleanup ধাঁচ, free-র পর NULL |
printf(msg) যেখানে msg ব্যবহারকারীর লেখা | format string-এর অপব্যবহার | printf("%s", msg) |
AI-কে শুধু “কোডটা নিরাপদ কি না দেখো” বললে প্রায়ই সাধারণ, ভাসা ভাসা উত্তর আসে। প্রশ্নটিকে নির্দিষ্ট করুন — প্রতিটি buffer, প্রতিটি হিসাব, প্রতিটি বাইরের ইনপুট আলাদা করে দেখতে বলুন:
AI-এর লেখা C কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।
প্র ০১Compiler তো UB-এর সুযোগ নিয়ে কোড মুছে দেয়। তাহলে সে প্রতিটি UB-তে সোজাসুজি warning দেয় না কেন?
প্রশ্নটি স্বাভাবিক, কিন্তু এর পেছনে একটি ভুল ধারণা আছে — যেন compiler কোথাও “এখানে UB আছে” খুঁজে পায়, তারপর চুপচাপ সুযোগ নেয়। আসলে ঘটনা উল্টো। Compiler UB খোঁজে না; সে ধরে নেয় UB নেই, আর সেই অনুমান থেকে তথ্য বের করে। x + 1 < x দেখে সে ভাবে না “এখানে overflow হতে পারে”; ভাবে “এটি কখনো সত্য নয়”।
দ্বিতীয় কারণ — বেশিরভাগ UB নির্ভর করে চালানোর সময়ের মানের ওপর। a[i] UB কি না, তা নির্ভর করে i-এর মানের ওপর, যা হয়তো একটি ফাইল থেকে আসবে। a + b উপচাবে কি না, compile-এর সময় জানা অসম্ভব। কম্পিউটার বিজ্ঞানের একটি মৌলিক ফল (halting problem-এর পরিবারের) বলে, সাধারণভাবে যেকোনো প্রোগ্রামে কোনো একটি ঘটনা কখনো ঘটবে কি না, তা নিখুঁতভাবে নির্ণয় করা যায় না।
তৃতীয় কারণ — মিথ্যা সতর্কতার বন্যা। Macro আর inline ফাংশনের কারণে বাস্তব কোডে “কখনো সত্য হয় না এমন যাচাই” প্রচুর, আর তার বেশিরভাগই নির্দোষ। প্রতিটি মুছে ফেলা শাখায় warning দিলে বড় প্রজেক্টে হাজার হাজার সতর্কতা আসত, আর মানুষ সব warning উপেক্ষা করা শিখত — যা আরও খারাপ। LLVM-এর স্রষ্টা Chris Lattner ২০১১ সালে “What Every C Programmer Should Know About Undefined Behavior” নামের ব্লগ সিরিজে ঠিক এই কথাগুলোই ব্যাখ্যা করেছিলেন।
তাই সমাধান এসেছে অন্য দিক থেকে: চালানোর সময় যাচাই। UBSan প্রতিটি signed যোগ, shift, ভাগের আগে ছোট যাচাই বসায়; ASan প্রতিটি মেমরি-প্রবেশ পাহারা দেয়। এরা ধীর, তাই টেস্টে ব্যবহারের জন্য। সাথে static analyzer আর -fanalyzer-এর মতো টুল কিছু পথ compile-এর সময়ও খোঁজে, তবে সীমিতভাবে।
মূল উপলব্ধি: Compiler-এর নীরবতা মানে “আমি কোনো সমস্যা খুঁজে পাইনি”, “এখানে কোনো সমস্যা নেই” নয়। UB ধরার দায়িত্ব শেষ পর্যন্ত আপনার — sanitizer, টেস্ট আর অভ্যাস দিয়ে।
প্র ০২Rust-কে “memory-safe” বলা হয়। এর মানে আসলে কী নিশ্চয়তা — আর কী নিশ্চয়তা নয়?
“Memory-safe” একটি নির্দিষ্ট প্রতিশ্রুতি, সর্বরোগের ওষুধ নয়। Safe Rust (অর্থাৎ unsafe ব্লকের বাইরের কোড) প্রতিশ্রুতি দেয়: আপনার প্রোগ্রামে undefined behavior ঘটবে না। এর মধ্যে পড়ে:
- সীমার বাইরে প্রবেশ নেই: array-র index চালানোর সময় যাচাই হয়; সীমা পেরোলে প্রোগ্রাম নিয়ন্ত্রিতভাবে থামে (panic), পাশের মেমরিতে লেখে না।
- Use-after-free ও double free নেই: প্রতিটি মানের একজন মালিক; compiler compile-এর সময়ই প্রমাণ করে কোনো রেফারেন্স তার মালিকের চেয়ে বেশি দিন বাঁচে না।
- Data race নেই: একই ডেটায় একসাথে একাধিক লেখার পথ compiler অনুমতি দেয় না।
কিন্তু যা নিশ্চয়তা নয়: যুক্তির ভুল (ভুল সুদের হার), deadlock, মেমরি leak (Rust-এ leak “নিরাপদ” বলে গণ্য), panic দিয়ে প্রোগ্রাম বন্ধ হয়ে যাওয়া — যা নিজেই একটি denial of service হতে পারে। Integer overflow Rust-এ UB নয়, কিন্তু release build-এ সাধারণত wraparound হয় — অর্থাৎ অংশ ৬-এর মতো ভুল আকার-হিসাব তখনো সম্ভব, শুধু তার ফলে মেমরি-নষ্ট হয় না কারণ index যাচাই হয়। আর unsafe ব্লকে, বা C library ডাকার সময়, এই নিশ্চয়তা প্রোগ্রামারের হাতে ফিরে আসে — ঠিক C-এর মতো।
মনে রাখুন, Java, C#, Go, Python, JavaScript — এরাও memory-safe; তারা garbage collector আর চালানোর সময়ের যাচাই দিয়ে এটি অর্জন করে। Rust-এর বিশেষত্ব হলো garbage collector ছাড়াই, বেশিরভাগ যাচাই compile-এর সময় করে, C-এর কাছাকাছি গতিতে এই নিশ্চয়তা দেওয়া। C++ আধুনিক smart pointer দিয়ে ঝুঁকি অনেক কমায়, কিন্তু ভাষা হিসেবে memory-safe নয়।
মূল উপলব্ধি: memory-safe ভাষা একটি পুরো শ্রেণির বাগকে অসম্ভব করে দেয় — আর সেই শ্রেণিটিই বড় C/C++ কোডবেসের গুরুতর নিরাপত্তা-ত্রুটির বড় অংশ। কিন্তু নিরাপদ সফটওয়্যারের জন্য তবুও লাগে ভালো নকশা, ইনপুট যাচাই আর টেস্ট।
প্র ০৩আধুনিক OS-এ ASLR, NX আর stack canary সবই আছে। তাহলে buffer overflow নিয়ে আজও এত দুশ্চিন্তা কেন?
কারণ এই তিনটি প্রতিরক্ষা বাগকে সারায় না — শুধু বাগ কাজে লাগানোর কিছু নির্দিষ্ট পথ কঠিন করে। প্রতিটির নিজস্ব সীমা আছে:
- Canary পাহারা দেয় শুধু return address-এর ঠিক আগের জায়গা। একই frame-এর অন্য গুরুত্বপূর্ণ ভ্যারিয়েবল — যেমন একটি “অনুমতি আছে কি না” flag বা একটি function pointer — নষ্ট হলে canary কিছুই টের পায় না। heap-এর overflow-তে stack canary কোনো কাজেই আসে না।
- ASLR একটি গোপন তথ্যের ওপর দাঁড়িয়ে: “কোথায় কী আছে, কেউ জানে না।” প্রোগ্রামের অন্য কোনো বাগ যদি একটিমাত্র ঠিকানাও ফাঁস করে দেয়, বাকিগুলো হিসাব করে বের করা সহজ হয়ে যায়। ৩২-বিট সিস্টেমে এলোমেলো করার জায়গা এত কম যে বারবার চেষ্টাতেই মিলে যেতে পারে।
- NX বাইরে থেকে আনা কোড চালানো ঠেকায়। কিন্তু প্রোগ্রাম আর library-র ভেতরে যে বৈধ কোড আগে থেকেই আছে, তার অপব্যবহারের কৌশল নিরাপত্তা গবেষকেরা বহু বছর আগেই দেখিয়েছেন। এজন্যই Intel ও ARM-এর নতুন প্রসেসরে shadow stack আর branch-যাচাইয়ের মতো হার্ডওয়্যার-সুরক্ষা এসেছে।
তার ওপর আছে এমন বাগ, যেগুলো এই তিনটির আওতাতেই পড়ে না। Heartbleed কিছু লেখেনি, কোনো কোড চালায়নি — শুধু বেশি পড়েছে। কোনো সুরক্ষাই এটি থামাতে পারত না। আবার সব প্রতিরক্ষা সফল হলেও ফল হয় একটি crash; একটি সার্ভার বা ATM-এর সফটওয়্যারে বারবার crash নিজেই একটি গুরুতর সমস্যা।
ইতিহাস দেখায়, প্রতিটি নতুন mitigation আসার পর কয়েক বছরের মধ্যে গবেষকেরা তার সীমা খুঁজে বের করেছেন, আর তারপর আরেকটি স্তর এসেছে। এটি একটি চলমান প্রতিযোগিতা। এই প্রতিযোগিতা থেকে বেরোনোর একমাত্র স্থায়ী উপায় হলো বাগটিই না থাকা — নিরাপদ অভ্যাস, টেস্ট, fuzzing, অথবা এমন ভাষা যা ঐ শ্রেণির বাগ অসম্ভব করে।
মূল উপলব্ধি: mitigation হলো “বাগ থাকলেও ক্ষতি কমানো”। এগুলো অপরিহার্য, কিন্তু এগুলোর ভরসায় নিরাপত্তা দাঁড়াতে পারে না — নিরাপত্তা শুরু হয় বাগ না লেখা থেকে।
প্র ০৪২০০৯ সালের tun ড্রাইভারের ঘটনার পর Linux kernel -fno-delete-null-pointer-checks দিয়ে compile হয়। দোষটা আসলে কার ছিল — compiler-এর, না kernel-এর?
অংশ ৪-এ ঘটনাটি দেখেছেন: kernel-এর কোডে আগে tun->sk পড়া হয়েছিল, তারপর tun null কি না যাচাই। GCC স্ট্যান্ডার্ড মেনেই যুক্তি দিল — null pointer পড়া UB, তাই এখানে tun null হতে পারে না, যাচাইটি অপ্রয়োজনীয় — এবং যাচাইটি মুছে দিল। স্ট্যান্ডার্ডের দৃষ্টিতে compiler সম্পূর্ণ সঠিক; ভুল kernel-এর কোডে, যেখানে যাচাইটি ব্যবহারের পরে লেখা।
কিন্তু kernel-এর জগৎ সাধারণ প্রোগ্রামের মতো নয়। সাধারণ প্রোগ্রামে ঠিকানা ০ কখনো বৈধ মেমরি নয়, তাই null পড়লে নিশ্চিতভাবে crash হয় — অর্থাৎ “পড়ার পর যাচাই” আসলে কখনো কাজে লাগত না, মুছলেও ক্ষতি নেই। Kernel-এ পরিস্থিতি আলাদা: তখনকার কিছু সিস্টেমে ঠিকানা ০-তেও মেমরি বসানোর পথ খোলা ছিল। ফলে null পড়া crash না করে চুপচাপ কিছু একটা পড়ে নিত, আর মুছে যাওয়া যাচাইটিই ছিল শেষ প্রতিরক্ষা। নিরাপত্তা গবেষকেরা দেখালেন, এই সমন্বয় kernel-এর জন্য গুরুতর ঝুঁকি।
প্রতিক্রিয়া এসেছিল দুই স্তরে। Kernel নিজের build-এ flag যোগ করল, যাতে compiler এমন null-check আর না মোছে — অর্থাৎ kernel সচেতনভাবে স্ট্যান্ডার্ডের একটি অনুমান বাতিল করল। আর ঠিকানা ০-র কাছে মেমরি বসানোর সুযোগ আরও কঠোরভাবে বন্ধ করা হলো। Kernel আগে থেকেই -fno-strict-aliasing ব্যবহার করত (পর্ব ৪৫), আর signed overflow নিয়ে compiler-এর অনুমান বন্ধ রাখতে -fno-strict-overflow-ও ব্যবহার করে।
এই ঘটনা থেকে C সম্প্রদায়ে একটি দীর্ঘ বিতর্ক শুরু হয়: compiler-এর কি স্ট্যান্ডার্ডের প্রতিটি অনুমতি কাজে লাগানো উচিত, নাকি বাস্তব কোডের প্রত্যাশাও বিবেচনা করা উচিত? Linus Torvalds বহুবার প্রকাশ্যে বলেছেন, তাঁর মতে কিছু UB-ভিত্তিক optimization লাভের চেয়ে বেশি ক্ষতি করে। Compiler নির্মাতারা উত্তর দেন, এই optimization-গুলোই সাধারণ কোডকে দ্রুত করে, আর যাঁর দরকার নেই তাঁর জন্য flag আছে।
মূল উপলব্ধি: কোডটি ভুল ছিল, compiler নিয়ম মেনেছিল — তবুও ফল হয়েছিল নিরাপত্তা-ঝুঁকি। যেখানে ভুলের দাম অনেক বেশি, সেখানে বুদ্ধিমানের কাজ হলো compiler-এর বিপজ্জনক অনুমানগুলো সচেতনভাবে বন্ধ করা, আর একই সাথে কোডকে নিয়ম মেনে লেখা।
প্র ০৫UB এত সমস্যা তৈরি করে, তবুও C কমিটি স্ট্যান্ডার্ড থেকে এটি তুলে দেয় না কেন? C23-এ two's complement বাধ্যতামূলক হলো, তবুও signed overflow কেন UB থেকে গেল?
C স্ট্যান্ডার্ড দেখাশোনা করে ISO-র একটি কমিটি, যাকে বলা হয় WG14. তাদের সামনে কয়েকটি শক্ত বাস্তবতা আছে।
- পেছনের দিকে সামঞ্জস্য: কয়েক দশকের কোড আর অসংখ্য compiler এখনকার নিয়মের ওপর দাঁড়িয়ে। কোনো UB-কে নির্দিষ্ট আচরণে বদলালে কিছু হার্ডওয়্যারে বা কিছু compiler-এ সেই নিয়ম মানতে বাড়তি কোড লাগতে পারে।
- গতি: signed overflow UB বলেই compiler একটি
for (int i = 0; i < n; i++)লুপে ধরে নিতে পারেiকখনো ঘুরে ঋণাত্মক হবে না। ৬৪-বিট CPU-তে এতেi-কে সরাসরি ৬৪-বিট রেজিস্টারে রাখা, লুপ কতবার ঘুরবে আগেই হিসাব করা, আর vector নির্দেশ ব্যবহার করা সহজ হয়। Overflow-কে wraparound বানালে এমন কিছু optimization হারাত। - ভুল ধরার সুযোগ: একটি সূক্ষ্ম যুক্তি আছে — overflow যেহেতু UB, তাই UBSan-এর মতো টুল প্রতিটি overflow-কে “ভুল” বলে ধরতে পারে। Wraparound বৈধ হলে টুলের কাছে সেটি আর ভুল নয়, যদিও প্রায় সবসময় সেটি প্রোগ্রামারের ভুলই।
তাই C23-এর সিদ্ধান্তটি সূক্ষ্ম: সংখ্যা কীভাবে রাখা হয় — two's complement — সেটি বাধ্যতামূলক হলো, কারণ বাস্তবে আর কোনো প্রচলিত CPU অন্য পদ্ধতি ব্যবহার করে না। কিন্তু উপচালে কী হবে, সেটি ইচ্ছা করেই undefined রাখা হলো। বিকল্প হিসেবে যাঁর wraparound চাই, তাঁর জন্য GCC-র -fwrapv আছে; যাঁর নিরাপদ যাচাই চাই, তাঁর জন্য C23-এর ckd_add.
তবে পরিবর্তনও চলছে। পরের সংস্করণ (কাজের নাম C2y) নিয়ে আলোচনায় কমিটিতে অপ্রয়োজনীয় UB-গুলো একে একে সরানো, বা কিছু ক্ষেত্রে আরও নির্দিষ্ট আচরণ দেওয়ার প্রস্তাব এসেছে। C++ কমিটিও C++26-এ uninitialised ভ্যারিয়েবল পড়ার মতো কিছু ক্ষেত্রকে UB থেকে সরিয়ে “erroneous behavior” নামের নতুন, তুলনামূলক নিরাপদ শ্রেণিতে এনেছে। দিক পরিষ্কার: ধীরে ধীরে, সামঞ্জস্য না ভেঙে, ঝুঁকি কমানো।
মূল উপলব্ধি: UB কোনো দুর্ঘটনা নয়, একটি নকশার সিদ্ধান্ত — গতি, portability আর সামঞ্জস্যের বিনিময়ে নিরাপত্তার দায় প্রোগ্রামারের কাঁধে দেওয়া। সেই দায় চিনে নেওয়াই এই পর্বের মূল শিক্ষা।
অনুশীলন
এগুলোর বেশিরভাগে নতুন প্রোগ্রাম লিখতে হবে না — বাগ খুঁজে বের করা আর সঠিক সিদ্ধান্ত নেওয়াই আসল কাজ।
-
বাগ খুঁজুন ও সারান: নিচের কোডে কী সমস্যা? কোন ইনপুটে সমস্যাটি দেখা দেবে, আর কীভাবে ঠিক করবেন?
void make_path(const char *dir, const char *file) { char path[64]; strcpy(path, dir); strcat(path, "/"); strcat(path, file); open_file(path); }dirআরfile-এর মোট দৈর্ঘ্য যাচাই হয়নি। দুটির দৈর্ঘ্য মিলিয়ে,"/"আর\0-সহ ৬৪ বাইটের বেশি হলেই stack-এ buffer overflow — যেমন একটি গভীর ফোল্ডারের নাম বা একটি লম্বা ফাইলের নাম। এক লাইনে সমাধান:int n = snprintf(path, sizeof path, "%s/%s", dir, file); if (n < 0 || (size_t)n >= sizeof path) return; /* বা error ফেরত */খেয়াল করুন, শুধু
snprintfদিলে overflow বন্ধ হয়, কিন্তু truncation-এর যাচাই না থাকলে কাটা পড়া ভুল path-এ ফাইল খোলা হতো — যা অন্য একটি ফাইল হতে পারে। বাস্তব কোডে সাধারণতPATH_MAX-এর মতো সিস্টেমের সীমা ব্যবহার করা হয়। -
বাদ পড়া যাচাই যোগ করুন: একটি ছবি-প্রক্রিয়ার প্রোগ্রাম ফাইলের header থেকে
uint32_t width, heightপড়ে, তারপর লেখেuint8_t *px = malloc(width * height * 4);(প্রতি pixel ৪ বাইট)। এখানে কোন কোন যাচাই দরকার?প্রথমত,
width * height * 4হিসাব হচ্ছেuint32_t-এ (৩২-বিট সিস্টেমেsize_t-ও তাই) —65536 × 65536হলেই গুণফল শূন্যে ঘুরে আসে, ছোট buffer আর বিশাল লেখা। দরকারি যাচাই:- শূন্য বাতিল:
width == 0 || height == 0হলে ফাইল অবৈধ। - যুক্তিসঙ্গত সর্বোচ্চ সীমা: যেমন প্রতিটি দিক ১৬,৩৮৪-এর বেশি হলে বাতিল — এটি ভুল ফাইলের বিরুদ্ধে প্রথম প্রতিরক্ষা।
- গুণের আগে overflow যাচাই, সব হিসাব
size_t-এ:if ((size_t)width > SIZE_MAX / 4 / height) fail;অথবা দুবার__builtin_mul_overflow. malloc-এর ফল NULL কি না।
পরে pixel পড়ার লুপেও দেখতে হবে, ফাইলে সত্যিই
width * height * 4বাইট আছে কি না — header যা বলে, ফাইলের আসল দৈর্ঘ্য তা নাও হতে পারে (Heartbleed-এর শিক্ষা)। - শূন্য বাতিল:
-
সঠিক flag বেছে নিন: নিচের চারটি পরিস্থিতিতে কোন flag বা টুল ব্যবহার করবেন? (ক) টেস্টের সময় use-after-free খুঁজতে চান। (খ) দলের সবাই যেন কোনো warning উপেক্ষা করতে না পারেন। (গ) ব্যবহারকারীদের জন্য release build শক্ত করতে চান। (ঘ) টেস্টে signed overflow আর অতিরিক্ত shift ধরতে চান।
(ক)
-fsanitize=address -g— AddressSanitizer free করা মেমরিতে প্রবেশ ধরে, ঠিক কোন লাইনে free হয়েছিল আর কোথায় আবার ব্যবহার হলো, দুটোই দেখায় (পর্ব ৩১)। বিকল্প Valgrind, তবে ধীর।(খ)
-Wall -Wextra -Werror— warning-কে error বানায়। CI-তে চালু রাখলে warning-ওয়ালা কোড merge-ই হবে না।(গ)
-O2 -D_FORTIFY_SOURCE=2 -fstack-protector-strong -fPIE -pie. মনে রাখুন,_FORTIFY_SOURCEoptimization ছাড়া কাজ করে না। Sanitizer release-এ নয় — এটি ধীর, আর এটি নিরাপত্তা-সুরক্ষা হিসেবে নকশা করা হয়নি।(ঘ)
-fsanitize=undefined(UBSan) — signed overflow, অতিরিক্ত shift, শূন্য দিয়ে ভাগ, ভুল alignment ইত্যাদি চালানোর সময় ধরে, আর ঠিক লাইন নম্বর দেখায়। -
নিরাপদ API দিয়ে নতুন করে লিখুন: একটি পুরোনো প্রোগ্রামে আছে
char line[80]; gets(line);আরchar msg[64]; sprintf(msg, "Order %d for %s", id, line);. এই দুটি লাইন নিরাপদভাবে লিখুন।char line[80]; if (fgets(line, sizeof line, stdin) == NULL) return -1; /* EOF বা error */ line[strcspn(line, "\n")] = '\0'; /* শেষের newline সরানো */ char msg[64]; int n = snprintf(msg, sizeof msg, "Order %d for %s", id, line); if (n < 0 || (size_t)n >= sizeof msg) return -1; /* কাটা পড়েছে */fgetsসর্বোচ্চ ৭৯ অক্ষর পড়ে, আর শেষে সবসময়\0দেয়। কিন্তু সে newline-ও রেখে দেয়, তাইstrcspnদিয়ে সরানো হলো (পর্ব ১৯)। লাইনটি ৭৯ অক্ষরের বেশি হলে বাকিটা ইনপুটে থেকে যায় — দরকার হলে সেটিও পড়ে ফেলে দিতে হবে। আরmsg-এ “Order ” আর সংখ্যার পর ৭৯ অক্ষরের নাম আঁটবে না, তাই truncation যাচাইটি এখানে সত্যিই কাজে লাগে।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমএকটি C প্রোগ্রামের জীবনে প্রতিরক্ষার স্তরগুলো যে ক্রমে কাজ করে, সাজান — কোড লেখা থেকে ব্যবহারকারীর কম্পিউটার পর্যন্ত (▲▼ চেপে সরান)
- নিরাপদ অভ্যাসে কোড লেখা — সীমাযুক্ত ফাংশন, আগে যাচাই
- Compile-এর সময়
-Wall -Wextra -Werrorwarning - টেস্টে sanitizer (ASan, UBSan) দিয়ে চালানো
- Fuzzing দিয়ে অসংখ্য অদ্ভুত ইনপুটে পরীক্ষা
- Hardening flag দিয়ে release build
- ব্যবহারকারীর কম্পিউটারে OS-এর NX ও ASLR
নিচের কোনটি C-তে undefined behavior?
unsigned u = UINT_MAX; u + 1int x = INT_MAX; x + 1sizeof(long)-এর মান ৮ হওয়াf(a(), b())-তে কোনটি আগে চলবে
int x-এর জন্য লেখা if (x + 1 < x) reject(); — -O2-তে compile করলে GCC সাধারণত কী করে?
- compile error দেয়
- overflow হলে প্রোগ্রাম থামানোর যাচাই বসায়
- শর্তটিকে সবসময় মিথ্যা ধরে যাচাইটি মুছে দিতে পারে
- হার্ডওয়্যারের মতো wraparound ধরে যাচাইটি ঠিক রাখে
৩২-বিট size_t-এ count = 1073741825 হলে malloc(count * 4) আসলে কত বাইট চায়?
- ৪ বাইট
4294967300বাইট- malloc NULL ফেরত দেয়, কারণ সংখ্যাটি অবৈধ
- এটি UB, তাই কিছু বলা যায় না
char buf[16]; — snprintf(buf, sizeof buf, ...) ফেরত দিল 25. এর মানে কী?
- buf-এ ২৫ বাইট লেখা হয়েছে, overflow হয়েছে
- একটি encoding error হয়েছে
- ২৫টি অক্ষর সফলভাবে buf-এ আছে
- লেখা কাটা পড়েছে; পুরোটার জন্য ২৬ বাইট দরকার ছিল
কোন flag optimization (-O1 বা বেশি) ছাড়া কার্যকর হয় না?
-Wall-D_FORTIFY_SOURCE=2-fstack-protector-strong-fsanitize=address
২০১৪ সালের Heartbleed কোন ধরনের বাগ ছিল?
- stack-এ buffer overflow (লেখা)
- use-after-free
- bounds check বাদ পড়া out-of-bounds read
- format string-এর অপব্যবহার
C23 সম্পর্কে কোন বাক্যটি সঠিক?
- Two's complement বাধ্যতামূলক, তবুও signed overflow UB;
<stdckdint.h>-এckd_addএসেছে - Two's complement বাধ্যতামূলক হওয়ায় signed overflow এখন নিয়ম মেনে ঘুরে আসে
- সব array-প্রবেশে স্বয়ংক্রিয় bounds check যোগ হয়েছে
- C23-এ প্রথমবার
getsআরstrcpyবাদ দেওয়া হয়েছে
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- UB মানে চুক্তি বাতিল। Compiler ধরে নেয় UB ঘটে না — তাই UB-ওয়ালা কোড অন্য flag বা compiler-এ সম্পূর্ণ ভিন্ন আচরণ করতে পারে, এমনকি আপনার যাচাইও মুছে যেতে পারে।
- Implementation-defined লিখিত, unspecified কয়েকটির একটি, undefined কোনো শর্তই নেই। Unsigned wraparound বৈধ, signed overflow UB — C23-এও।
- বড় C/C++ কোডবেসে গুরুতর নিরাপত্তা-ত্রুটির প্রায় ৭০% মেমরি-নিরাপত্তার ভুল (Microsoft, Chromium): সীমার বাইরে লেখা-পড়া, use-after-free, আকার-হিসাবে overflow.
- হিসাবের আগে যাচাই:
count > SIZE_MAX / size,__builtin_mul_overflowবাckd_mul; string-এsnprintfআর তার ফেরত মান যাচাই। - স্তরে স্তরে প্রতিরক্ষা: অভ্যাস →
-Wall -Wextra -Werror→ sanitizer ও fuzzing →_FORTIFY_SOURCE, canary, PIE → NX ও ASLR. শেষের স্তরগুলো ক্ষতি কমায়, বাগ সারায় না।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৪৭ · আধুনিক C — C99 থেকে C23পরবর্তী পর্বভাষাটি কীভাবে বদলেছে, আর কোন নতুন সুবিধা আজই ব্যবহার করবেন।
- C প্রোগ্রামিং · কোর্স হোম৫৬টি পর্বপুরো কোর্সের রোডম্যাপ — কোন পর্ব কখন।
- Cybersecurityগভীরে যানvulnerability, আক্রমণের ধরন আর প্রতিরক্ষা — নিরাপত্তার পুরো ছবি।
- Programming Languages & Compilersপাশের ট্র্যাকoptimizer কীভাবে যুক্তি চালায়, আর ভাষার নকশা কীভাবে নিরাপত্তা ঠিক করে।
- Operating Systemsপাশের ট্র্যাকvirtual memory, ASLR, NX আর process-এর অনুমতি — OS-এর দিক থেকে।