לדלג לתוכן

10.4 ניתוח CVE ו n day תרגול

תרגול - ניתוח CVE ו-n-day

בתרגול הזה נשחק את התפקיד של חוקר n-day. נקבל טלאי (patch) של תוכנית קטנה בדיוק כמו שהוא מופיע ב-advisory, נצטרך לזהות מהו הבאג שהוכנס ואז תוקן, ולכתוב PoC שמקריס את הגרסה הvulnerable. שימו לב: פה לא נלמד את הבאג מראש, המטרה היא שתגלו אותו בעצמכם מהטלאי, בדיוק כמו במציאות.

רקע - החולשה שקיבלנו

נניח שיצא advisory כזה:

Advisory: KVAULT-2024-002
Component: kvault key-value store
Affected:  kvault <= 2.1.0
Fixed in:  kvault 2.1.1
Type:      CWE-787 Out-of-bounds Write in set_value()
Summary:   A crafted length value sent to the SET command can cause a write
           past the end of a stack buffer.

ה-advisory מפנה ל-commit שתיקן, וזה ה-diff המלא שלו:

--- a/store.c
+++ b/store.c
@@ -20,7 +20,7 @@ void set_value(void) {
     if (read_exact(0, &len, sizeof(len)) < 0)
         return;

-    if (len > MAX_VALUE) {
+    if (len < 0 || len > MAX_VALUE) {
         puts("value too long");
         return;
     }

זה כל השינוי. שורה אחת. הגרסה הvulnerable המלאה (kvault 2.1.0, לפני הטלאי):

// store.c  -  kvault 2.1.0 (the vulnerable version)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#define MAX_VALUE 256

static int read_exact(int fd, void *buf, size_t n) {
    size_t got = 0;
    while (got < n) {
        ssize_t r = read(fd, (char *)buf + got, n - got);
        if (r <= 0) return -1;
        got += r;
    }
    return 0;
}

void set_value(void) {
    char value[MAX_VALUE];
    int len;

    if (read_exact(0, &len, sizeof(len)) < 0)   // reads 4 bytes as a signed int
        return;

    if (len > MAX_VALUE) {
        puts("value too long");
        return;
    }

    read(0, value, len);                        // the attacker controls len
    printf("stored %d bytes\n", len);
}

int main(void) {
    setvbuf(stdout, NULL, _IONBF, 0);
    puts("kvault 2.1.0 - SET");
    set_value();
    return 0;
}

הפרוטוקול פשוט: התוכנית קוראת 4 בתים בתור אורך (מספר שלם עם סימן, little-endian), ואז קוראת את מספר הבתים הזה אל תוך value.


תרגיל 1 - לזהות את הבאג מהטלאי

לפני שנוגעים במקלדת, קוראים את ה-diff כמו חוקר.

המשימות:

  1. הטלאי שינה בדיוק תנאי אחד: מ-if (len > MAX_VALUE) ל-if (len < 0 || len > MAX_VALUE). נסחו במשפט אחד מה הגרסה הישנה פספסה.
  2. שימו לב לטיפוס של len. הוא int עם סימן, ונקרא ישירות מ-4 בתים שהתוקף שולט בהם. איזה טווח ערכים הוא יכול לקבל, וכולל אילו ערכים שליליים?
  3. הסתכלו על הקריאה read(0, value, len). מהו הטיפוס של הארגומנט השלישי של read לפי חתימת הפונקציה? מה קורה לערך len שלילי כשהוא מומר לטיפוס הזה?
  4. הסיקו: מהו הפרימיטיב שהתוקף מקבל? באיזה CWE מדובר, והאם זה תואם ל-advisory?

רמז: הבדיקה len > MAX_VALUE מגנה רק מפני מספרים גדולים מדי. היא לא אומרת כלום על מספרים קטנים מ-0.

רמז: הארגומנט השלישי של read הוא size_t, שהוא חסר סימן. מה הופך -1 כשמפרשים אותו כ-size_t ברוחב 64 ביט?


תרגיל 2 - לבנות ולהריץ את הגרסה הvulnerable

עכשיו נהפוך את התיאוריה לקובץ שאפשר לתקוף.

המשימות:

  1. שמרו את הגרסה הvulnerable כ-store.c והדרו אותה. התחילו בלי canary כדי לראות קריסה נקייה:
gcc -fno-stack-protector -no-pie -g -o store store.c
  1. הריצו checksec --file=./store ורשמו את מצב ה-RELRO, Canary, NX ו-PIE.
  2. הריצו את התוכנית ידנית עם קלט תקין (אורך קטן וכמה בתים) וודאו שהיא מתנהגת כרגיל. חשוב לוודא שהמסלול התקין עובד לפני שתוקפים אותו.
  3. פתחו את set_value ב-GDB עם disassemble set_value ומצאו באיזו כתובת יחסית ל-rbp יושב הbuffer value. כמה בתים מתחילת value ועד הreturn address?

רמז: הbuffer בגודל 256, אז ההסטה לreturn address היא בסביבות 256 + 8. אמתו את המספר המדויק מהדיסאסמבלי, הקומפיילר יכול להוסיף padding alignment.


תרגיל 3 - לכתוב PoC שמקריס

זה התרגיל המרכזי. המטרה: להפעיל את הבאג ולגרום לקריסה, בלי לנחש בעיוורון.

המשימות:

  1. חשבו איזה אורך לשלוח כדי שהבדיקה len > MAX_VALUE תעבור בשלום, אבל read תכתוב הרבה מעבר ל-256 בתים. שימו לב שמספר גדול חיובי (למשל 999999) ייחסם על ידי הבדיקה, אז החשיבה צריכה ללכת לכיוון אחר.
  2. כתבו סקריפט pwntools שמפעיל את process('./store'), שולח את שדה האורך (4 בתים), ואז שולח מספיק בתים כדי לגלוש ולדרוס את כתובת החזרה.
  3. הריצו ובדקו שקיבלתם SIGSEGV. זה ה-PoC שלכם.
  4. הידרו מחדש בלי -fno-stack-protector (כלומר עם canary), הריצו שוב את אותו PoC, וראו איך הקריסה משתנה מ-SIGSEGV ל-stack smashing detected. שתיהן קריסות תקפות שמוכיחות את הבאג.

רמז: אם len שלילי, הוא הופך למספר עצום כ-size_t. read תקרא כמה בתים שתשלחו לתוך buffer של 256. שלחו למשל 400 בתים.

רמז: את שדה האורך שולחים כ-4 בתים בינאריים. הערך -1 הוא p32(0xffffffff), והוא עובר את הבדיקה len > MAX_VALUE כי כ-int עם סימן הוא שלילי.

רמז לשלד ה-PoC:

from pwn import *

context.binary = elf = ELF('./store')
p = process('./store')

p.recvuntil(b'SET')
p.send(p32(????))          # the length field - what value?
p.send(b'A' * 400)         # the bytes that overflow
print(p.recvall(timeout=2))

בונוס - להסביר למה הטלאי מספיק

הטלאי הוסיף רק len < 0. הוכיחו לעצמכם שאחרי התיקון ה-PoC שלכם כבר לא עובד: הידרו את הגרסה המתוקנת (עם התנאי החדש) והריצו מולה את אותו PoC. הסבירו בדיוק איזה קלט מה-PoC נחסם עכשיו, ולמה מספר גדול חיובי לא היה עוזר לתוקף כבר בגרסה הישנה (הבדיקה len > MAX_VALUE תפסה אותו). זו בדיוק סוג הבדיקה שחוקר עושה כדי לוודא שהוא הבין את שורש הבעיה נכון.