לדלג לתוכן

1.6 הזרקת פקודות הרצאה

עד עכשיו בפרק הזה ראינו באגים לוגיים, השוואות שבורות ואקראיות צפויה - חולשות שנובעות מטעות בלוגיקה של התוכנית. עכשיו נעבור לקטגוריה שונה ומאוד נפוצה בעולם האמיתי: הcommand injection. הרעיון פשוט. תוכנית לוקחת קלט מהמשתמש ומעבירה אותו, במלואו או בחלקו, למעטפת המערכת (shell). אם התוקף מצליח להבריח לתוך המחרוזת הזו תווים מיוחדים, הוא לא רק משנה את הפקודה - הוא מריץ פקודות משלו, בהרשאות של התוכנית.

בהרצאה הזו נבין בדיוק איך system() מדברת עם המעטפת, מהם תווי המטא שהמעטפת מפרשת, ולמה מסננים שמנסים לחסום מילים אסורות כמעט תמיד נשברים. את כל זה נתרגל על שלושה אתגרים קלאסיים מ-pwnable.kr: cmd1, cmd2 ו-shellshock. שימו לב: כל מה שנלמד כאן הוא ידע הגנתי. כדי לכתוב קוד שלא נשבר, צריך להבין קודם איך תוקף שובר אותו.


מהי הזרקת פקודות - command injection

הcommand injection קורית כשתוכנית בונה מחרוזת פקודה ומעבירה אותה למעטפת, כשחלק מהמחרוזת מגיע מהמשתמש. הדוגמה הקלאסית בקוד C:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>

int main(int argc, char **argv) {
    char cmd[256];
    // the developer just wanted to ping an address the user provides
    snprintf(cmd, sizeof(cmd), "ping -c 1 %s", argv[1]);
    system(cmd);   // this is where the system breaks
    return 0;
}

המתכנת דמיין שהמשתמש יכניס 8.8.8.8, והתוצאה תהיה ping -c 1 8.8.8.8. אבל מה קורה אם המשתמש מכניס 8.8.8.8; cat /etc/passwd? המחרוזת הופכת ל-ping -c 1 8.8.8.8; cat /etc/passwd, והמעטפת מריצה שתי פקודות. התו ; הוא מפריד פקודות, והמעטפת לא יודעת שלא התכוונו לתת למשתמש שליטה עליו.

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


איך system() מריצה פקודות - system

הפונקציה system() היא לא קסם. מתחת למכסה המנוע היא עושה בערך את זה:

// roughly what system("cmd string") does
execl("/bin/sh", "sh", "-c", "cmd string", (char *)0);

כלומר system() תמיד מריצה /bin/sh -c "<המחרוזת שלכם>". זו נקודה קריטית: המחרוזת לא מורצת ישירות, אלא נמסרת למעטפת, וזו מפרשת אותה מלא. כל מה שהמעטפת יודעת לעשות - הפרדת פקודות, צנרת, הרחבת משתנים, גלוב (globbing), הרחבת פרמטרים - הכל זמין לתוקף.

שימו לב שגם ל-popen() ולפונקציות דומות יש בדיוק אותה בעיה, כי גם הן עוברות דרך /bin/sh -c. לעומת זאת execve() שמקבלת מערך ארגומנטים מפורש לא עוברת דרך מעטפת, ולכן היא הרבה יותר בטוחה. ההבדל הזה הוא בדיוק הלקח ההגנתי של הפרק.

עוד פרט חשוב: על אובונטו ודביאן, /bin/sh הוא בדרך כלל dash, לא bash. לרוב זה לא משנה לתוקף, אבל חלק מהטריקים (כמו הרחבת תת-מחרוזת) הם ביזם - bashism - ולא יעבדו תחת dash. נחזור לזה.


תווי המטא של המעטפת - shell metacharacters

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

;        command separator (run one, then the next)
&&  ||   run the next on success / failure
|        pipe - output of one into input of the next
&        run in the background
$(...)    command substitution - run, and replace with the output
`...`     old-style command substitution (backticks)
$VAR     environment variable expansion
${VAR}   variable expansion with explicit boundaries
*  ?  [] globbing - filename matching
{a,b}    brace expansion
> < >>   input/output redirection
'  "  \  quoting and escaping
$IFS     internal field separator (default: space/tab/newline)

כל אחד מאלה הוא נשק אפשרי. למשל, אם מסנן חוסם רווחים, נשתמש ב-${IFS} במקום רווח. אם מסנן חוסם את התו /, נבנה אותו מהרחבת משתנה. תכף נראה בדיוק איך.


סינון קלט ולמה הוא נשבר - input filtering

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

יש שתי גישות לסינון:

  • רשימה שחורה - blacklist: חוסמים תווים או מילים "רעים" ומרשים את השאר. זו הגישה השברירית. תמיד יש עוד תו, עוד קידוד, עוד דרך לבטא את אותה כוונה שהמפתח שכח לחסום.
  • רשימה לבנה - whitelist: מרשים רק תווים "טובים" (למשל, רק ספרות ונקודות עבור כתובת IP) ופוסלים את השאר. זו הגישה הבטוחה.

האתגרים cmd1 ו-cmd2 מדגימים בדיוק את הכשל של רשימה שחורה. הם משתמשים ב-strstr() כדי לחפש תת-מחרוזות אסורות בקלט. הבעיה: strstr() מחפש את המחרוזת בדיוק כפי שהיא. אם המעטפת יכולה לבטא את אותה מילה בצורה אחרת - למשל לפצל אותה בציטוט ריק, או לבנות אותה מהרחבת משתנה - המסנן לא רואה כלום, אבל המעטפת מריצה בדיוק את מה שרצינו.

בואו נראה דוגמה למסנן טיפוסי בסגנון cmd1:

int filter(char *cmd) {
    int r = 0;
    r += strstr(cmd, "flag") != 0;   // must not mention the file flag
    r += strstr(cmd, "sh")   != 0;   // must not run a shell
    r += strstr(cmd, "tmp")  != 0;   // must not touch /tmp
    return r;   // if the returned value is greater than 0, the input is rejected
}

ובנוסף, לפני ה-system(), התוכנית עושה putenv("PATH=/thisisnotapath") - כלומר משבשת את משתנה ה-PATH, כך שאי אפשר סתם להריץ cat ולצפות שהמעטפת תמצא אותו. שני החסמים האלה - סינון המילים והשבתת ה-PATH - הם מה שנצטרך לעקוף.


טכניקות עקיפה - filter bypass

יש משפחה שלמה של טריקים לעקיפת מסנני תת-מחרוזת. כל אחד מנצל פער בין מה שהמסנן רואה (מחרוזת גולמית) לבין מה שהמעטפת מבצעת (אחרי פרוש והרחבה). נעבור על העיקריים.

פיצול מילים בציטוט - word splitting

הטריק הבסיסי והחשוב ביותר. המעטפת מסירה ציטוטים לפני שהיא מריצה, אבל strstr() רואה אותם:

# the filter looks for "flag". We write:
fl""ag      # empty double quote in the middle
fl''ag      # empty single quote
fl\ag       # no space, the backslash just disappears
f'l'a'g'    # split into several parts

בכל אחת מהצורות האלה, strstr(cmd, "flag") מחזיר NULL - כי הרצף f-l-a-g לא מופיע ברציפות בתוך המחרוזת הגולמית. אבל אחרי שהמעטפת מסירה את הציטוטים, נשארת בדיוק המילה flag. זה הפער שאנחנו מנצלים.

גלוב - globbing

המעטפת מרחיבה תבניות של שמות קבצים לפני ההרצה. * מתאים לכל רצף תווים, ? מתאים לתו בודד:

cat fl?g      # ? matches any single character -> flag
cat fl*       # * matches the rest -> flag
cat f*        # matches any file starting with f
cat *         # matches all files in the directory, including flag

המחרוזת fl?g לא מכילה את הרצף flag, אז המסנן מרוצה. אבל המעטפת, כשהיא רואה ?, סורקת את התיקייה ומחליפה את התבנית בשמות הקבצים המתאימים - וכך fl?g הופך ל-flag. הגלוב שימושי במיוחד כשגם שם הקובץ וגם שמות בינאריים חסומים.

הרחבת פרמטרים ובניית תווים - parameter expansion

מה עושים כשגם התו / חסום, ואי אפשר לכתוב נתיב מלא כמו /bin/cat? בונים את התו מתוך משתנה שכבר מכיל אותו. תחת bash אפשר לחתוך תת-מחרוזת:

# $PWD is always an absolute path starting with "/", e.g. /home/cmd2
echo ${PWD:0:1}          # the first character of $PWD -> "/"
echo ${PWD:0:1}bin${PWD:0:1}sh   # assembles the string "/bin/sh"

הרחבת התת-מחרוזת ${VAR:offset:length} היא תכונה של bash. תחת dash (שהוא לרוב /bin/sh באובונטו) היא לא נתמכת. לכן חשוב לדעת שהיא תעבוד רק כשהמעטפת שמריצה היא bash. נראה בפתרון גם שיטה חלופית שלא תלויה בזה.

רווחים בלי רווח - IFS

אם מסנן חוסם רווחים, אפשר להשתמש במשתנה $IFS שברירת המחדל שלו כוללת רווח:

cat${IFS}flag      # ${IFS} expands to a space -> "cat flag"
{cat,flag}         # brace expansion produces "cat flag" without an explicit space

הזרקת נתיב ל-PATH - PATH tricks

אחרי putenv("PATH=/thisisnotapath"), המעטפת לא תמצא פקודות לפי שם קצר. שתי דרכים לעקוף:

  • נתיב מוחלט: פשוט נכתוב /bin/cat במקום cat. הבעיה מתחילה כשגם / חסום.
  • אתחול PATH מחדש: אם התו = מותר (כמו ב-cmd1), נכתוב השמה מקומית לפני הפקודה:
PATH=/bin cat fl?g       # sets PATH only for this command, so cat is found

טריק חזק במיוחד: אפשר להגדיר משתנה סביבה מראש, במעטפת ההתחברות שלנו, לפני שאנחנו מריצים את התוכנית הvulnerable. המסנן בודק רק את argv, לא את הסביבה. אז אם נגדיר A=/bin/cat במעטפת שלנו ונעביר לתוכנית רק את המחרוזת $A *, המסנן לא רואה שום תו אסור - כל התווים הבעייתיים חבויים בתוך המשתנה. התוכנית מריצה /bin/sh -c '$A *', והמעטפת מרחיבה את $A לערך שהכנו. זו אחת הדרכים הנקיות ביותר לעקוף את cmd2, ונחזור אליה.


אתגר ראשון - cmd1

עכשיו נחבר את הכל לאתגר cmd1 מ-pwnable.kr. מתחברים כך:

ssh cmd1@pwnable.kr -p2222
# the password appears on the pwnable.kr site next to the challenge

בתיקיית הבית תמצאו שלושה קבצים: cmd1 (הבינארי, עם ביט setuid כך שהוא רץ בהרשאות המשתמש שיכול לקרוא את הדגל), cmd1.c (המקור), ו-flag (הדגל, קריא רק למשתמש המורשה).

המבנה כפי שראינו: המסנן חוסם את תת-המחרוזות flag, sh, tmp, ולפני ה-system() יש putenv("PATH=/thisisnotapath"). המטרה: לגרום לתוכנית להריץ פקודה שקוראת את flag, בלי להזכיר את המילה flag, בלי sh, ובלי להסתמך על PATH תקין.

הרעיון: נשתמש בנתיב מוחלט (/bin/cat) כדי לעקוף את שיבוש ה-PATH, ובגלוב או ציטוט ריק כדי להימנע מהמילה flag. את הצעד המדויק נשאיר לתרגול.


אתגר שני - cmd2

האתגר cmd2 הוא הגרסה המחמירה. מתחברים:

ssh cmd2@pwnable.kr -p2222

המסנן כאן חוסם הרבה יותר: את התווים והמילים =, PATH, export, /, את התו backtick, ואת flag. גם כאן ה-PATH מושבת (מוגדר לתיקייה שאין בה פקודות).

עכשיו קשה בהרבה. אי אפשר לכתוב /bin/cat כי / חסום. אי אפשר PATH=/bin כי גם = וגם PATH וגם / חסומים. אי אפשר להשתמש ב-backticks. אז מה כן אפשר?

שלוש דרכים עיקריות, וכולן מנצלות את הפער בין המסנן למעטפת:

  • בניית התו / מתוך משתנה: ${PWD:0:1} נותן / (עובד רק אם /bin/sh הוא bash).
  • גלוב לשמות בינאריים וקבצים: ? ו-* מייצרים שמות בלי לכתוב את המילה flag במפורש.
  • הזרקת משתנה סביבה מוכן מראש: מגדירים משתנה שמכיל את כל התווים האסורים במעטפת ההתחברות, ומעבירים לתוכנית רק את שם המשתנה. זו השיטה החזקה והנקייה ביותר, והיא לא תלויה בסוג המעטפת.

את הפירוט המלא של כל דרך נראה בפתרון.


חולשת Shellshock - CVE-2014-6271

האתגר השלישי שונה במהותו. הוא לא מסתמך על מסנן שנשבר, אלא על באג אמיתי ומפורסם ב-bash עצמו: Shellshock, שקיבל את המזהה CVE-2014-6271. זו הייתה אחת החולשות הכי חמורות של 2014, כי היא אפשרה הרצת קוד מרחוק בשרתי אינטרנט רבים (למשל דרך CGI).

שורש הבאג - exported functions

בbash אפשר לייצא לא רק משתנים אלא גם פונקציות לתהליכי בת, דרך משתני סביבה. כשbash חדש מתחיל, הוא סורק את הסביבה, ואם ערך של משתנה מתחיל ב-() { הוא מפרש אותו כהגדרת פונקציה ומייבא אותה. עד כאן תכונה לגיטימית.

הבאג: המפרש (parser) לא עצר בסוף הגדרת הפונקציה. אם אחרי ההגדרה הופיע עוד קוד, bash הריץ אותו מיד, בזמן ההפעלה, עוד לפני שקרא פקודה כלשהי. כלומר, כל מי ששולט בערך של משתנה סביבה שיגיע ל-bash - יכול להריץ פקודה שרירותית.

הפריצה הקלאסית - the classic payload

הנה הבדיקה המפורסמת שכולם הריצו ב-2014 כדי לדעת אם המערכת vulnerable:

env x='() { :;}; echo vulnerable' bash -c "echo test"

בואו נפרק את זה:

x='() { :;}; echo vulnerable'
  |          |
  |          +--> the trailing code: runs immediately when bash starts
  +--> innocent function definition: () { :; }   (the : command does nothing)

אם המערכת vulnerable, הפלט יהיה:

vulnerable
test

כלומר echo vulnerable רץ בזמן שה-bash סתם התחיל, עוד לפני שהריץ את echo test. אם המערכת מתוקנת, יודפס רק test (ואולי אזהרה על ייבוא פונקציה שגוי). הנקודה: על מערכת vulnerable, עצם ההפעלה של bash עם המשתנה הזרוע מריצה את הקוד שלנו.

למה זה מסוכן כל כך

חשבו על שרת אינטרנט שמריץ סקריפט CGI. פרוטוקול CGI מעביר כותרות HTTP לתוך משתני סביבה (למשל User-Agent הופך ל-HTTP_USER_AGENT). אם הסקריפט מפעיל bash, תוקף יכול לשלוח כותרת User-Agent: () { :;}; <פקודה> והפקודה תרוץ בשרת. זה בדיוק מה שקרה בהתקפות ההמוניות אז.

הקשר לאתגר shellshock ב-pwnable.kr

מתחברים:

ssh shellshock@pwnable.kr -p2222

באתגר יש בינארי shellshock (עם setuid/setgid למשתמש שיכול לקרוא את הדגל) שמריץ גרסה vulnerable של bash שנשמרה במיוחד בתיקייה /home/shellshock/bash. הבינארי קורא בקירוב ל:

setresuid(getegid(), getegid(), getegid());
setresgid(getegid(), getegid(), getegid());
system("/home/shellshock/bash -c 'echo shock_me'");

כלומר, הוא מקבע את ההרשאות הגבוהות (כדי שיישמרו בתהליך הבן) ואז מריץ את ה-bash הvulnerable. ה-bash הזה יסרוק את הסביבה, ואם נזרע בה משתנה עם payload בסגנון Shellshock - הקוד הנגרר ירוץ בהרשאות הגבוהות, ויאפשר לקרוא את הדגל. את ה-payload המדויק נבנה בתרגול.

שימו לב לפרט עדין: המחרוזת עוברת קודם דרך /bin/sh -c ... (שהוא dash, לא vulnerable ל-Shellshock, ולכן פשוט מתעלם מהמשתנה שלנו ומעביר אותו הלאה), ורק אז מגיעה ל-bash הvulnerable - שם הבאג מופעל.


סיכום

בהרצאה הזו למדנו:

  • הcommand injection קורית כשקלט משתמש מגיע למעטפת דרך system()/popen(), והמעטפת מפרשת אותו כקוד ולא כנתון.
  • הפונקציה system() תמיד מריצה /bin/sh -c "<מחרוזת>", ולכן כל תווי המטא של המעטפת זמינים לתוקף. הפתרון ההגנתי: execve() עם מערך ארגומנטים מפורש, בלי מעטפת.
  • תווי מטא כמו ;, |, $(), *, ?, ${} ו-$IFS הם הכלים לעקיפת מסננים.
  • מסנני רשימה שחורה (blacklist) עם strstr() נשברים כמעט תמיד: פיצול בציטוט ריק (fl''ag), גלוב (fl?g), הרחבת פרמטרים (${PWD:0:1}) והזרקת משתני סביבה מוכנים מראש - כולם עוקפים אותם. רק רשימה לבנה (whitelist) באמת מגנה.
  • האתגר cmd1 חוסם flag/sh/tmp ומשבש PATH - עוקפים בנתיב מוחלט וגלוב.
  • האתגר cmd2 חוסם גם / ו-= - עוקפים בבניית תווים ממשתנים ובהזרקת משתנה סביבה.
  • החולשה Shellshock (CVE-2014-6271) מנצלת באג בפרסור של הגדרות פונקציה מיוצאות ב-bash: קוד שנגרר אחרי הגדרת הפונקציה במשתנה סביבה רץ מיד בהפעלת bash.

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