التقنيات المستخدمة في محلل كود PVS-Studio للبحث عن الأخطاء ونقاط الضعف المحتملة

التكنولوجيا والسحر

وصف موجز للتقنيات المستخدمة في أداة PVS-Studio التي يمكنها اكتشاف عدد كبير من أنماط الأخطاء ونقاط الضعف المحتملة بشكل فعال. توضح المقالة تنفيذ محلل لشفرة C و C ++ ، ومع ذلك ، فإن المعلومات المذكورة أعلاه صالحة أيضًا للوحدات المسؤولة عن تحليل C # و Java code.

مقدمة


هناك مفاهيم خاطئة مفادها أن محللات الشفرة الثابتة هي برامج بسيطة تستند إلى البحث عن أنماط الشفرة باستخدام التعبيرات العادية. هذا بعيد عن الحقيقة. علاوة على ذلك ، من غير الممكن تحديد الغالبية العظمى من الأخطاء باستخدام التعبيرات العادية.

نشأ الخطأ على أساس تجربة المبرمجين عند العمل مع بعض الأدوات التي كانت موجودة قبل 10-20 سنة. غالبًا ما توصل عمل الأدوات إلى إيجاد أنماط خطيرة من التعليمات البرمجية والوظائف مثل strcpy و strcat وما إلى ذلك. كممثل لهذه الفئة من الأدوات يمكن أن يسمى RATS .

هذه الأدوات ، على الرغم من أنها يمكن أن تكون مفيدة ، كانت غبية وغير فعالة بشكل عام. من تلك الأوقات ، لا يزال العديد من المبرمجين لديهم ذكريات أن المحللون الثابتون هم أدوات غير مجدية للغاية تتدخل في العمل أكثر من مساعدتها.

مر الوقت ، وبدأ المحللون الثابتون في تشكيل حلول معقدة تقوم بتحليل متعمق للرمز وتجد الأخطاء التي لا تزال موجودة في الكود حتى بعد مراجعة الكود اليقظة. لسوء الحظ ، نظرًا للتجارب السلبية السابقة ، لا يزال العديد من المبرمجين يعتبرون منهجية التحليل الثابت عديمة الجدوى وليسوا في عجلة من أمرهم لإدخالها في عملية التطوير.

في هذه المقالة سأحاول إصلاح الموقف قليلاً. أطلب من القراء أن يستغرقوا 15 دقيقة للتعرف على التقنيات المستخدمة في محلل الأكواد الثابتة PVS-Studio لاكتشاف الأخطاء. ربما بعد ذلك ستلقي نظرة جديدة على أدوات التحليل الثابت وترغب في تطبيقها في عملك.

تحليل تدفق البيانات


يسمح لك تحليل دفق البيانات بالعثور على مجموعة متنوعة من الأخطاء. من بينها: الخروج من حدود المصفوفة ، وتسريبات الذاكرة ، والظروف الصحيحة / الخاطئة دائمًا ، وإلغاء الإشارة إلى مؤشر فارغ وما إلى ذلك.

أيضًا ، يمكن استخدام تحليل البيانات للبحث عن المواقف عند استخدام البيانات التي لم يتم التحقق منها والتي جاءت إلى البرنامج من الخارج. يمكن للمهاجم إعداد مثل هذه المجموعة من بيانات الإدخال لجعل البرنامج يعمل بالطريقة التي يحتاجها. وبعبارة أخرى ، يمكن أن تستخدم خطأ عدم كفاية التحكم في المدخلات كنقطة ضعف. للبحث عن استخدام البيانات التي لم يتم التحقق منها في PVS-Studio ، تم تنفيذ التشخيص المتخصص V1010 ويستمر في التحسن.

تحليل تدفق البيانات (تحليل تدفق البيانات ) هو حساب القيم المحتملة للمتغيرات في نقاط مختلفة في برنامج الكمبيوتر. على سبيل المثال ، إذا تم إلغاء الإشارة إلى المؤشر ، ومن المعروف أنه في هذه اللحظة يمكن أن يكون صفرًا ، فهذا خطأ ، وسوف يقوم المحلل الثابت بالإبلاغ عنه.

دعونا نلقي نظرة على مثال عملي لاستخدام تحليل تدفق البيانات للبحث عن الأخطاء. أمامنا وظيفة من مشروع مخازن البروتوكول (protobuf) ، المصممة للتحقق من صحة التاريخ.

static const int kDaysInMonth[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; bool ValidateDateTime(const DateTime& time) { if (time.year < 1 || time.year > 9999 || time.month < 1 || time.month > 12 || time.day < 1 || time.day > 31 || time.hour < 0 || time.hour > 23 || time.minute < 0 || time.minute > 59 || time.second < 0 || time.second > 59) { return false; } if (time.month == 2 && IsLeapYear(time.year)) { return time.month <= kDaysInMonth[time.month] + 1; } else { return time.month <= kDaysInMonth[time.month]; } } 

اكتشف محلل PVS-Studio خطأين منطقيين في الوظيفة في وقت واحد ويعرض الرسائل التالية:

  • V547 / CWE-571 التعبير 'time.month <= kDaysInMonth [time.month] + 1' صحيح دائمًا. الوقت ccc 83
  • V547 / CWE-571 التعبير "time.month <= kDaysInMonth [time.month]" صحيح دائمًا. time.cc 85

لاحظ التعبير الفرعي "time.month <1 || time.month> 12 ". إذا كانت قيمة الشهر خارج النطاق [1..12] ، فإن الوظيفة تتوقف عن عملها. يأخذ المحلل هذا في الاعتبار ويعرف أنه إذا بدأ تنفيذ العبارة if الثانية ، فإن قيمة الشهر تقع بالضبط في النطاق [1..12]. وبالمثل ، فهو يعرف نطاق المتغيرات الأخرى (السنة ، اليوم ، إلخ) ، لكنها ليست مثيرة للاهتمام بالنسبة لنا الآن.

الآن دعونا نلقي نظرة على عاملين متطابقين للوصول إلى عناصر المصفوفة: kDaysInMonth [time.month] .

يتم تعيين الصفيف بشكل ثابت ، ويعرف المحلل قيم جميع عناصره:

 static const int kDaysInMonth[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; 

نظرًا لأن الأشهر مُرقمة من 1 ، فإن المحلل لا يعتبر 0 في بداية المصفوفة. وتبين أنه يمكن استخلاص قيمة في النطاق [28..31] من المصفوفة.

اعتمادًا على ما إذا كانت السنة سنة كبيسة أم لا ، تتم إضافة 1 إلى عدد الأيام ، ولكن هذا أيضًا ليس مثيرًا للاهتمام بالنسبة لنا الآن. المقارنات نفسها مهمة:

 time.month <= kDaysInMonth[time.month] + 1; time.month <= kDaysInMonth[time.month]; 

تتم مقارنة النطاق [1..12] (رقم الشهر) بعدد الأيام في الشهر.

بالنظر إلى أنه في الحالة الأولى يكون الشهر دائمًا هو شهر فبراير ( time.month == 2 ) ، نحصل على مقارنة النطاقات التالية:

  • 2 <= 29
  • [1..12] <= [28..31]

كما ترون ، فإن نتيجة المقارنة صحيحة دائمًا ، وهو ما يحذر منه محلل PVS-Studio. في الواقع ، يحتوي الرمز على نوعين من الأخطاء المطبعية المتطابقة. يجب أن يستخدم الجانب الأيسر من التعبير عضوًا في الفصل الدراسي اليومي وليس شهرًا على الإطلاق.

يجب أن يكون الرمز الصحيح كما يلي:

 if (time.month == 2 && IsLeapYear(time.year)) { return time.day <= kDaysInMonth[time.month] + 1; } else { return time.day <= kDaysInMonth[time.month]; } 

تم وصف الخطأ الذي تمت مناقشته هنا سابقًا أيضًا في المقالة " 31 فبراير ".

تنفيذ رمزي


في القسم السابق ، نظرنا في طريقة حيث يحسب المحلل القيم المحتملة للمتغيرات. ومع ذلك ، للعثور على بعض الأخطاء ، ليس من الضروري معرفة قيم المتغيرات. التنفيذ الرمزي يعني حل المعادلات بشكل رمزي.

لم أجد عرضًا توضيحيًا مناسبًا في قاعدة بيانات الأخطاء الخاصة بنا ، لذا ضع في اعتبارك مثالاً على رمز اصطناعي.

 int Foo(int A, int B) { if (A == B) return 10 / (A - B); return 1; } 

يقوم محلل PVS-Studio بإنشاء تحذير V609 / CWE-369 قسمة على صفر. المقام "أ - ب" == 0. test.cpp 12

قيم المتغيرات A و B غير معروفة للمحلل. لكن المحلل يعرف أنه في لحظة حساب التعبير 10 / (أ - ب) ، يكون المتغيران أ و ب متساويين. لذلك ، سيحدث القسمة على 0.

قلت إن قيم A و B غير معروفة. بالنسبة للحالة العامة ، هذا صحيح. ومع ذلك ، إذا رأى المحلل استدعاء دالة بقيم محددة للوسائط الفعلية ، فسيأخذ ذلك في الاعتبار. فكر في مثال:

 int Div(int X) { return 10 / X; } void Foo() { for (int i = 0; i < 5; ++i) Div(i); } 

محلل PVS-Studio يحذر من القسمة على صفر: V609 CWE-628 القسمة على صفر. المقام "X" == 0. تعالج الدالة "Div" القيمة "[0..4]". افحص الحجة الأولى. تحقق من الخطوط: 106 ، 110. consoleapplication2017cpp 106

يعمل مزيج من التقنيات هنا بالفعل: تحليل تدفق البيانات والتنفيذ الرمزي والتعليق التلقائي للطريقة (سنناقش هذه التقنية في القسم التالي). يرى المحلل أن المتغير X يستخدم كمقسوم في دالة Div . بناءً على ذلك ، يتم إنشاء تعليق توضيحي خاص تلقائيًا لوظيفة Div . يؤخذ أيضًا في الاعتبار أنه يتم تمرير نطاق من القيم [0..4] إلى الدالة كوسيطة X. يستنتج المحلل أن القسمة على 0 يجب أن تحدث.

شروح الطريقة


قام فريقنا بتعليق آلاف الوظائف والفصول المقدمة في:

  • وينابي
  • مكتبة C القياسية
  • مكتبة النماذج القياسية (STL) ،
  • glibc (مكتبة GNU C)
  • كيو تي
  • Mfc
  • زليب
  • libpng
  • مفتوح
  • وهكذا دواليك

يتم شرح جميع الوظائف يدويًا ، مما يسمح لك بتعيين العديد من الخصائص المهمة من حيث العثور على الأخطاء. على سبيل المثال ، تم تحديد أن حجم المخزن المؤقت الذي تم تمريره إلى وظيفة fread يجب ألا يقل عن عدد وحدات البايت التي تم التخطيط لقراءتها من الملف. يشار أيضًا إلى العلاقة بين الوسيطتين الثانية والثالثة والقيمة التي يمكن أن ترجعها الدالة. يبدو كل هذا كما يلي:

PVS-Studio: ترميز الوظائف

بفضل هذا التعليق التوضيحي ، ستكشف الشفرة التالية ، التي تستخدم وظيفة fread ، خطأين على الفور.

 void Foo(FILE *f) { char buf[100]; size_t i = fread(buf, sizeof(char), 1000, f); buf[i] = 1; .... } 

تحذيرات PVS-Studio:
  • V512 CWE-119 سيؤدي استدعاء وظيفة "fread" إلى تجاوز سعة المخزن المؤقت "buf". test.cpp 116
  • V557 CWE-787 تجاوز الصفيف ممكن. يمكن أن تصل قيمة مؤشر "i" إلى 1000. test.cpp 117

أولاً ، ضرب المحلل الحجة الفعلية الثانية والثالثة وحسب أن الوظيفة يمكنها قراءة ما يصل إلى 1000 بايت من البيانات. في هذه الحالة ، حجم المخزن المؤقت هو 100 بايت فقط ، وقد يتجاوز.

ثانيًا ، نظرًا لأن الوظيفة يمكنها قراءة ما يصل إلى 1000 بايت ، فإن نطاق القيم المحتملة للمتغير i هو [0..1000]. وفقًا لذلك ، قد يحدث الوصول إلى الصفيف في فهرس خاطئ.

دعونا نلقي نظرة على مثال بسيط آخر لخطأ ، تم الكشف عنه بفضل ترميز وظيفة memset . فيما يلي مقتطف رمز لمشروع CryEngine V.

 void EnableFloatExceptions(....) { .... CONTEXT ctx; memset(&ctx, sizeof(ctx), 0); .... } 

وجد محلل PVS-Studio خطأ مطبعيًا: V575 تقوم وظيفة "memset" بمعالجة عناصر "0". افحص الحجة الثالثة. 294

الخلط بين الوسيطة الثانية والثالثة للدالة. ونتيجة لذلك ، تعالج الدالة 0 بايت ولا تفعل شيئًا. يلاحظ المحلل هذا الوضع الشاذ ويحذر المبرمجين بشأنه. في وقت سابق ، وصفنا بالفعل هذا الخطأ في المقالة " فحص CryEngine V الذي طال انتظاره ".

لا يقتصر محلل PVS-Studio على التعليقات التوضيحية التي قمنا بتعيينها يدويًا. بالإضافة إلى ذلك ، يحاول بشكل مستقل إنشاء التعليقات التوضيحية من خلال دراسة أجسام الوظائف. هذا يسمح لك بالعثور على أخطاء الاستخدام غير السليم للوظائف. على سبيل المثال ، يتذكر المحلل أن الدالة يمكنها إرجاع nullptr. إذا تم استخدام المؤشر الذي تم إرجاعه بواسطة هذه الوظيفة دون فحص أولي ، فسيحذر المحلل من ذلك. مثال:

 int GlobalInt; int *Get() { return (rand() % 2) ? nullptr : &GlobalInt; } void Use() { *Get() = 1; } 

تحذير: V522 CWE-690 قد يكون هناك إحالة مرجعية لمؤشر فارغ محتمل 'Get ()'. test.cpp 129

ملاحظة يمكنك الاقتراب من البحث عن الخطأ الذي تم فحصه للتو في الاتجاه المعاكس. لا تتذكر أي شيء ، وفي كل مرة تتم فيها مواجهة استدعاء دالة Get ، قم بتحليلها مع معرفة الحجج الفعلية. تسمح لك هذه الخوارزمية نظريًا بالعثور على المزيد من الأخطاء ، ولكن لها تعقيدًا أسيًا. ينمو وقت تحليل البرنامج مئات الآلاف من المرات ، ونحن نعتبر هذا النهج طريقًا مسدودًا من وجهة نظر عملية. في PVS-Studio ، نقوم بتطوير اتجاه التعليقات التوضيحية التلقائية للوظائف.

مطابقة الأنماط


قد تبدو مطابقة التكنولوجيا مع النمط ، للوهلة الأولى ، مثل البحث باستخدام التعبيرات العادية. في الواقع ، الأمر ليس كذلك ، وكل شيء أكثر تعقيدًا.

أولاً ، كما قلت سابقًا ، فإن التعبيرات العادية لا قيمة لها بشكل عام. ثانيًا ، لا يعمل المحللون مع أسطر النص ، ولكن مع أشجار بناء الجملة ، مما يسمح للمرء بالتعرف على أنماط الخطأ الأكثر تعقيدًا وعالية المستوى.

تأمل في مثالين ، أحدهما أبسط والآخر أكثر تعقيدًا. الخطأ الأول الذي وجدته أثناء التحقق من رمز المصدر لنظام Android.

 void TagMonitor::parseTagsToMonitor(String8 tagNames) { std::lock_guard<std::mutex> lock(mMonitorMutex); if (ssize_t idx = tagNames.find("3a") != -1) { ssize_t end = tagNames.find(",", idx); char* start = tagNames.lockBuffer(tagNames.size()); start[idx] = '\0'; .... } .... } 

يتعرف محلل PVS-Studio على نمط الخطأ الكلاسيكي المرتبط بالمفهوم الخاطئ للمبرمج حول أولوية العمليات في C ++: V593 / CWE-783 ضع في اعتبارك مراجعة تعبير النوع 'A = B! = C'. يتم حساب التعبير كما يلي: 'A = (B! = C)'. تاج مونيتور 50 صفحة

نلقي نظرة فاحصة على هذا الخط:

 if (ssize_t idx = tagNames.find("3a") != -1) { 

مبرمج يفترض أن يتم تنفيذ المهمة في البداية ، وعندها فقط مقارنة مع -1 . في الواقع ، تأتي المقارنة أولاً. كلاسيك تم وصف هذا الخطأ بمزيد من التفاصيل في المقالة المخصصة للتحقق من Android (راجع فصل "أخطاء أخرى").

الآن فكر في خيار مطابقة نمط أعلى مستوى.

 static inline void sha1ProcessChunk(....) { .... quint8 chunkBuffer[64]; .... #ifdef SHA1_WIPE_VARIABLES .... memset(chunkBuffer, 0, 64); #endif } 

تحذير PVS-Studio: V597 CWE-14 يمكن للمترجم حذف استدعاء وظيفة "memset" ، والذي يتم استخدامه لمسح المخزن المؤقت "chunkBuffer". يجب استخدام الدالة RtlSecureZeroMemory () لمسح البيانات الخاصة. sha1.cpp 189

جوهر المشكلة هو أنه بعد ملء المخزن المؤقت بالأصفار باستخدام وظيفة memset ، لا يتم استخدام هذا المخزن المؤقت في أي مكان. عند تجميع الكود مع أعلام التحسين ، سيقرر المترجم أن استدعاء الوظيفة هذا متكرر وسيحذفه. لديه الحق في ذلك ، لأنه من وجهة نظر لغة C ++ ، فإن استدعاء دالة ليس له أي سلوك يمكن ملاحظته في البرنامج. بعد ملء المخزن المؤقت chunkBuffer مباشرة ، تنتهي وظيفة sha1ProcessChunk . نظرًا لأن المخزن المؤقت يتم إنشاؤه على المكدس ، بعد الخروج من الوظيفة ، سيصبح غير متاح للاستخدام. لذلك ، من وجهة نظر المترجم ، لا معنى لملئه بالأصفار.

ونتيجة لذلك ، ستبقى البيانات الخاصة في مكان ما على المكدس ، مما قد يؤدي إلى حدوث مشكلات. تتم مناقشة هذا الموضوع بمزيد من التفصيل في مقالة " التنظيف الآمن للبيانات الخاصة ".

هذا مثال على مطابقة الأنماط عالية المستوى. أولاً ، يجب أن يكون المحلل على دراية بوجود هذا العيب الأمني ​​، المصنف وفقًا لتعداد الضعف العام كـ CWE-14: إزالة المترجم من التعليمات البرمجية لمسح المخازن المؤقتة .

ثانيًا ، يجب أن تجد في الشفرة جميع الأماكن التي يتم فيها إنشاء المخزن المؤقت على المكدس ، ويتم مسحها باستخدام وظيفة memset ولا يتم استخدامها في أي مكان آخر.

الخلاصة


كما ترى ، يعتبر التحليل الثابت منهجية مثيرة للاهتمام ومفيدة للغاية. يسمح لك بإزالة عدد كبير من الأخطاء ونقاط الضعف المحتملة في المراحل الأولى (انظر SAST ). إذا كنت لا تزال غير مشبع تمامًا بالتحليل الثابت ، فأنا أدعوك لقراءة مدونتنا ، حيث نقوم بانتظام بتحليل الأخطاء الموجودة باستخدام PVS-Studio في مشاريع مختلفة. لا يمكنك ببساطة أن تظل غير مبال.

يسعدنا أن نرى شركتك بين عملائنا ونساعدك في جعل تطبيقاتك أفضل وأكثر موثوقية وأمانًا.



إذا كنت تريد مشاركة هذه المقالة مع جمهور ناطق باللغة الإنجليزية ، فيرجى استخدام رابط الترجمة: Andrey Karpov. التقنيات المستخدمة في محلل كود PVS-Studio للعثور على الأخطاء ونقاط الضعف المحتملة .

Source: https://habr.com/ru/post/ar430604/


All Articles