לדלג לתוכן

1.1 תיאורי קבצים וארגומנטים הרצאה

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

בקורס הליבה כבר ראיתם את read, את write ואת מבנה ה-main. כאן ניקח את אותם רכיבים ונסתכל עליהם דרך העיניים של תוקף: לא "איך אני קורא קלט", אלא "אם המתכנת השתמש ב-read על תיאור קובץ שאני שולט בו, מה אני יכול לגרום לתוכנית לקרוא".

תיאורי קבצים - file descriptors

תיאור קובץ (file descriptor, בקיצור fd) הוא פשוט מספר שלם קטן שהkernel נותן לתהליך כדי לזהות ערוץ קלט או פלט פתוח. כל תהליך מחזיק טבלה של תיאורי קבצים, וכל כניסה בטבלה מצביעה על אובייקט קובץ בkernel: קובץ רגיל, מסוף (terminal), pipe, socket, ועוד. הביטוי המפורסם "בלינוקס הכל הוא קובץ" מתכוון בדיוק לזה - כולם נגישים דרך אותו interface של תיאורי קבצים.

שלושת תיאורי הקבצים הראשונים קבועים ומוכרים, ולכל תהליך יש אותם ברירת מחדל:

process
+-------+-----------------------------+
|  fd   | what it's connected to      |
+-------+-----------------------------+
|   0   | stdin  - standard input     |
|   1   | stdout - standard output    |
|   2   | stderr - error output       |
|   3   | first file opened with open |
|   4   | socket / pipe / extra file  |
|  ...  |                             |
+-------+-----------------------------+

כשהתוכנית קוראת read(0, buf, n) היא קוראת מ-stdin. כשהיא קוראת write(1, buf, n) היא כותבת ל-stdout. אלה לא מספרי קסם, אלה סתם אינדקסים בטבלה. וזו התובנה החשובה: התוכנית לא באמת יודעת מאיפה מגיע ה-fd. אם fd מספר 0 מחובר למקלדת, ל-pipe, לקובץ או ל-socket - read(0, ...) פשוט קוראת משם. מי ששולט במה שמחובר ל-fd, שולט בקלט.

פתיחה, קריאה, כתיבה - open, read, write

חמש קריאות המערכת שחשוב שיהיו לכם בראש:

int  open(const char *path, int flags, ...); // returns a new fd (the smallest available)
ssize_t read(int fd, void *buf, size_t n);   // reads up to n bytes, returns how many were read
ssize_t write(int fd, const void *buf, size_t n); // writes n bytes
int  close(int fd);
int  dup2(int oldfd, int newfd);             // makes newfd point to the same place as oldfd

שימו לב לערכי ההחזרה. read מחזירה את מספר הבתים שנקראו בפועל, 0 בסוף הקובץ (EOF), או -1 בשגיאה. זה קריטי: אם התוכנית עושה read(fd, buf, 32) ו-fd הוא קובץ ריק או ערוץ סגור, יחזרו 0 בתים ו-buf יישאר כמו שהיה (או מלא אפסים אם הוא סטטי). הרבה באגים לוגיים חבויים בדיוק בהתעלמות של המתכנת מערך ההחזרה של read.

הקריאה dup2 חשובה לנו כתוקפים: היא מאפשרת "לחבר" fd מסוים למקום שאנחנו בוחרים. נשתמש בה בהמשך כדי לשתול לתהליך קלט מבוקר על fd 0 ועל fd 2.

ארגומנטים ומשתני סביבה - argc, argv, envp

הדרך השנייה שבה תוכנית מקבלת מידע היא שורת הפקודה. החתימה המלאה של main בלינוקס היא:

int main(int argc, char *argv[], char *envp[]);

הפרמטר argc הוא מספר הארגומנטים, argv הוא מערך מצביעים למחרוזות (המסתיים ב-NULL), ו-envp הוא מערך מצביעים למחרוזות מהצורה "NAME=VALUE" (גם הוא מסתיים ב-NULL). כך זה נראה בזיכרון עבור ./prog hello world:

argv --> +---------+     +----------------+
         | argv[0] | --> | "./prog\0"     |
         +---------+     +----------------+
         | argv[1] | --> | "hello\0"      |
         +---------+     +----------------+
         | argv[2] | --> | "world\0"      |
         +---------+     +----------------+
         | NULL    |
         +---------+
argc = 3        (argv[0] is counted too)

האיבר argv[0] הוא שם התוכנית עצמה, ולכן argc תמיד לפחות 1. הנקודה החשובה: כל איבר ב-argv הוא מחרוזת C שמסתיימת ב-null, אבל האיבר עצמו יכול להכיל כל בית שהוא - כולל רווחים, תווי בקרה, ואפילו בית null באמצע (אם מי שבנה את המערך שם אותו שם). זה מוביל אותנו לנקודה מרכזית בהרצאה.

מה ה-shell מאפשר ומה לא

כשאתם מריצים ./prog hello world ב-shell, ה-shell עושה את פירוק המילים (word splitting): הוא מפצל את השורה לפי רווחים ובונה בשבילכם את מערך argv. וזו בדיוק המגבלה. יש ערכים שקשה או בלתי אפשרי להעביר כארגומנט דרך shell רגיל:

  • בית null (\x00) בתוך ארגומנט - בלתי אפשרי. ה-shell מסיים את המחרוזת ב-null, אז אי אפשר לשים null באמצע.
  • ארגומנט ריק לגמרי - אפשרי עם '' אבל קל לפספס.
  • רווח בתוך ארגומנט בודד - דורש מרכאות, אחרת ה-shell מפצל אותו לשני ארגומנטים.

מה עושים כשצריך להעביר ארגומנט שמכיל בית null או רווח בדיוק במקום מסוים? לא משתמשים ב-shell. בונים את מערך argv בעצמנו ומריצים את התוכנית ישירות עם execve, או בעזרת pwntools עם process(argv=[...]). זה בדיוק הטריק שנצטרך באתגר השני של השיעור הזה.

משתני סביבה - environment variables

משתני הסביבה הם ערוץ קלט שקל לשכוח ממנו. התוכנית ניגשת אליהם עם getenv("NAME"), שמחזיר מצביע לערך או NULL אם המשתנה לא קיים. אנחנו שולטים בהם לגמרי: ב-shell עם NAME=value ./prog, וב-pwntools/execve עם מילון env. גם כאן, דרך execve אנחנו יכולים לשתול שם משתנה או ערך שמכיל בתים שה-shell לעולם לא היה מרשה.

חמשת ערוצי הקלט של תהליך

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

ערוץ איך התוכנית קוראת איך אנחנו שולטים
קלט סטנדרטי - stdin (fd 0) read(0,...), scanf, gets, fgets pipe, הקלדה, p.send() ב-pwntools
ארגומנטים - argv argv[i] execve / process(argv=[...])
סביבה - environment getenv("X") env= ב-execve / pwntools
קבצים - files open/fopen ואז read/fread יוצרים את הקובץ בתיקיית העבודה
רשת - sockets socket/bind/accept או connect מתחברים לפורט עם socket / remote()

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

הטריק של atoi - שליטה ב-fd דרך מחרוזת

עכשיו לטריק הראשון והחשוב. הפונקציה atoi ממירה מחרוזת למספר שלם: atoi("4660") מחזיר 4660. מתכנתים משתמשים בה כדי לתרגם ארגומנט מספרי מ-argv. הבעיה מתחילה כשהמספר הזה הופך ל-תיאור קובץ. בואו נסתכל על בינארי דמו קטן (זו בדיוק הצורה של אתגר fd ב-pwnable.kr):

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

char buf[32];

int main(int argc, char *argv[]) {
    if (argc < 2) { puts("pass argv[1] a number"); return 0; }

    int fd = atoi(argv[1]) - 0x1234;   // the trick is here
    read(fd, buf, 32);

    if (!strcmp("LETMEWIN\n", buf)) {
        puts("good job :)");
        system("/bin/cat flag");
    } else {
        puts("learn about Linux file IO");
    }
    return 0;
}

מה קורה כאן? התוכנית לוקחת את הארגומנט הראשון, ממירה אותו למספר, מחסירה 0x1234, ומשתמשת בתוצאה כתיאור קובץ ל-read. המתכנת כנראה חשב שזה מסובך ובטוח. אבל אנחנו שולטים ב-argv[1], כלומר אנחנו שולטים במה ש-atoi מחזיר, כלומר אנחנו שולטים בערך של fd.

מה אנחנו רוצים? שה-read יקרא מ-stdin, כי ל-stdin אנחנו יכולים להזין כל דבר. stdin הוא fd מספר 0. אז אנחנו צריכים:

atoi(argv[1]) - 0x1234 == 0
atoi(argv[1]) == 0x1234
0x1234 in decimal = 4660

לכן אם נעביר 4660 כארגומנט, נקבל fd == 0, ואז read(0, buf, 32) יקרא מ-stdin, ואנחנו פשוט נזין LETMEWIN. ההשוואה היא מול "LETMEWIN\n" עם ירידת שורה, ולכן הקלדה של LETMEWIN ואז Enter (שמוסיף \n) מתאימה בול. נריץ:

./fd 4660
LETMEWIN

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

שליטה מדויקת בכל ערוץ - pwntools ו-os

ברוב האתגרים לא נסתפק בהקלדה ידנית. נרצה סקריפט שבונה את כל ערוצי הקלט במדויק. הכלי המרכזי שלנו הוא pwntools. הנה שלד שמדגים שליטה ב-argv, ב-env וב-stdin בבת אחת:

from pwn import *

argv = [b'./prog', b'4660']              # full control over argv, including special bytes
env  = {b'SECRET': b'\xca\xfe\xba\xbe'}   # control over environment variables

p = process(argv=argv, env=env)           # run without a shell -> exact argv
p.send(b'LETMEWIN\n')                      # send to stdin (fd 0)
print(p.recvall().decode())

הנקודה החשובה: process(argv=[...]) בונה את מערך הארגומנטים ישירות דרך execve, בלי ש-shell יתערב. לכן אפשר לשים בתוך איבר של argv בית null, רווח, או מחרוזת ריקה - דברים שה-shell לא היה מרשה. אותו דבר לגבי env: המילון עובר כמו שהוא.

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

with open('data.bin', 'wb') as f:
    f.write(b'\x00\x00\x00\x00')          # the content the program will read with fread

ועבור רשת, אם התוכנית מאזינה על פורט - מתחברים אליו:

c = remote('127.0.0.1', 31337)            # opens a socket and connects
c.send(b'\xde\xad\xbe\xef')

כשהקלט מגיע מ-fd 2 - קריאה מ-stderr

עכשיו מקרה מבלבל שכדאי להבין לעומק, כי נתקל בו מיד. בדרך כלל fd 2 (stderr) הוא ערוץ פלט - התוכנית כותבת אליו שגיאות. אבל אין שום כלל שמונע מתוכנית לקרוא ממנו: read(2, buf, 4) הוא קוד חוקי לגמרי. השאלה היא רק מה מחובר לצד השני של fd 2.

כשמריצים תוכנית מטרמינל, fd 2 מחובר לאותו מסוף כמו fd 1. אבל אנחנו לא חייבים לתת לתוכנית טרמינל. אנחנו יכולים ליצור pipe, לחבר את קצה הקריאה של ה-pipe ל-fd 2 של התהליך-הבן, ואז לכתוב אנחנו לקצה הכתיבה. עכשיו read(2, ...) בתהליך-הבן יקרא בדיוק את מה שאנחנו כתבנו:

parent process (our script)          child process (the targeted program)
   os.write(w, "...")  ----> [ pipe ] ----> read(fd 2, buf, 4)
       write end                            read end = fd 2

ב-Python נעשה את זה עם os.pipe(), os.fork() ו-os.dup2():

import os

r, w = os.pipe()                 # r = read end, w = write end
pid = os.fork()
if pid == 0:                     # child process
    os.dup2(r, 2)                # the child's fd 2 now points to the read end
    os.execve(b'/path/to/prog', [b'prog'], {})
    os._exit(1)
os.write(w, b'\x00\x0a\x02\xff') # we write, and the child will read this from fd 2

זה בדיוק אותו רעיון שנשתמש בו כדי להזין את fd 0 (stdin). כל fd הוא רק אינדקס בטבלה, ואנחנו מסדרים את הטבלה של התהליך-הבן לפני ה-execve.

הערה על mitigations

חשוב לומר את זה במפורש: האתגרים בשיעור הזה הם באגים לוגיים, לא חולשות זיכרון. אנחנו לא דורסים return address ולא מזריקים shellcode. לכן הmitigations שאתם מכירים - NX, ASLR, PIE, stack canary, RELRO - פשוט לא רלוונטיים כאן. אפשר להריץ checksec ./fd מתוך הרגל, אבל התוצאה לא תשנה כלום בגישה שלנו, כי אנחנו מנצלים היגיון שגוי ולא קורפשן זיכרון. זה שיעור טוב: לא כל exploit דורש עקיפת הגנות. לפעמים המתכנת פשוט נתן לכם את המפתח.

סיכום

  • תיאור קובץ - fd הוא אינדקס בטבלה של התהליך. 0 stdin, 1 stdout, 2 stderr, ומעליהם קבצים וסוקטים. התוכנית לא יודעת מאיפה באמת מגיע ה-fd.
  • לתהליך יש חמישה ערוצי קלט עיקריים: stdin, argv, סביבה, קבצים ורשת - וכולם בסופו של דבר תיאורי קבצים.
  • הטריק של atoi: כשערך שנשלט על ידי המשתמש הופך ל-fd, ערך הקצה שנותן fd == 0 נותן לנו שליטה מלאה בקלט. זו נשמת האתגר fd.
  • ה-shell מגביל אותנו: אי אפשר בית null בתוך ארגומנט, וקשה רווחים. הפתרון הוא לבנות את argv בעצמנו עם execve או process(argv=[...]).
  • אפשר להזין fd שהוא בדרך כלל פלט (למשל fd 2) על ידי חיבור קצה הקריאה של pipe אליו לפני ה-execve.
  • כלי העבודה: process(argv=, env=), יצירת קבצים בתיקיית העבודה, remote() לרשת, ו-os.pipe/os.fork/os.dup2 לשליטה ב-fd-ים ספציפיים.
  • אלה באגים לוגיים, ולכן NX/ASLR/PIE/canary/RELRO לא רלוונטיים לexploit שלהם.