Lua 기반의 후킹 스크립트 기능 구현에 대한 연구

Reversing

라온화이트햇 핵심연구팀 김재성

1. 개요


Lua는 C/C++에 이식되는 것을 염두하고 개발된 작고 가벼운 스크립트 언어입니다. 이미 작성된 프로그램의 컴파일 없이 코드를 실시간으로 변경할 수 있기 때문에 특히 게임에서 많이 활용되고 있는데 본문에서는 사용자가 함수 후킹을 쉽게 할 수 있도록 후킹 스크립트를 제공하는 목적으로 활용, 연구해보았습니다.

2. Lua 설치 및 예제


apt install -y lua5.3 liblua5.3-dev

운영체제 Ubuntu 18.04 기준으로 위와 같은 명령어로 Lua을 쉽게 설치할 수 있습니다.

정상적으로 설치되었다면 python처럼 인터프리터를 사용하여 Hello world를 출력할 수 있습니다.

/assets/2020-05-01/lua.png

lua-example.c

//gcc -o lua-example lua-example.c -llua5.3
#include "lua5.3/lua.h"
#include "lua5.3/lualib.h"
#include "lua5.3/lauxlib.h"
#include <stdio.h>

static int c_xor(lua_State *L)
{
    long argv_1 = lua_tointeger(L, 1);
    long argv_2 = lua_tointeger(L, 2);
    long result = argv_1 ^ argv_2;

    lua_pushinteger(L, result);
    return 1;
}

int main(void)
{
    int status, result, i;
    double sum;
    lua_State *L;

    L = luaL_newstate();

    luaL_openlibs(L);

    lua_pushcfunction(L, c_xor);
    lua_setglobal(L, "c_xor");
    
    luaL_dofile(L, "script.lua");
    lua_close(L);
    return 0;
}

script.lua

function printf(...)
    print(string.format(...))
end

x = 123
y = 456
printf("%d ^ %d = %d", x, y, c_xor(x, y))

위의 소스코드는 Lua 스크립트에서 C의 c_xor 함수를 호출하고 결과를 출력하는 예제입니다.

C에서 lua_pushcfunction 함수를 이용해 c_xor 함수를 등록하면 Lua에서 해당 함수를 호출할 수 있게 됩니다. 반대로 Lua에서 선언된 함수를 C에서 호출하는 것도 가능합니다.

c_xor 함수에서는 lua_tointeger 함수를 이용해 Lua로부터 전달받은 인자를 C의 long형으로 변환하여 가져왔는데 아래처럼 다른 형식으로도 인자를 가져올 수 있도록 여러 함수를 지원합니다.

Lua 형식 변환 함수

이외에 lua_toboolean 같은 함수들도 있는데 https://www.lua.org/manual/5.3/manual.html에서 자세한 레퍼런스를 확인할 수 있습니다.

함수 반환 값의 경우 Lua는 반환 값을 한번에 여러 개 전달할 수 있는데 여기서는 1개만 전달할 것이므로 result 변수 값을 푸시(lua_pushinteger(L, result))하고 return 1을 합니다.

/assets/2020-05-01/lua1.png

컴파일은 gcc -o lua-example lua-example.c -llua5.3 명령어로 가능하고, 예제 실행 결과는 123과 456을 xor한 435가 출력됩니다.

3. 후킹 스크립트 기능 구현


후킹 용도는 사용자마다 다양하기 때문에 아래와 같은 형식으로 후킹이 가능하면 좋을 것 같다고 판단하였습니다. 해당 인터페이스로 구현하면 사용자는 새롭게 정의한 함수에서 원본 함수를 호출하지 않을 수도 있고 함수의 인자값을 조작하거나 반환 값을 변경할 수 있습니다.

function new_open(pathname, flag)
    return original_open(pathname, flag)
end

hook("open", 2, "new_open", "original_open")

위와 같은 형태로 구현하기 위해서는 다음과 같은 과정이 필요합니다.

  1. hook 함수를 호출했을 때 open 함수 후킹
  2. 후킹된 open 함수가 호출되었을때 Lua에서 정의한 new_open 함수 호출
  3. Lua 내에서 original_open 함수를 호출했을때 원본 open 함수 호출
  4. Lua의 new_open 함수에서 반환된 값을 open 반환 값으로 전달

1번의 경우 편의상 frida-gum 모듈을 사용하였는데 원본 함수가 반드시 호출되는 Interceptor 대신 Replace 기능을 사용하였습니다. Replace의 경우 후킹된 함수가 반환하기 전에 다시 함수를 호출해주면 원본 함수가 호출되도록 구현되어있습니다.

		gum_init_embedded();
    interceptor = gum_interceptor_obtain();
    gum_interceptor_begin_transaction (interceptor);

    lua_pushcfunction(L, lua_replace_attach);
    lua_setglobal(L, "hook");

    lua_pushcfunction(L, lua_pointer_to_string);
    lua_setglobal(L, "p2string");

    lua_pushcfunction(L, lua_pointer_to_number);
    lua_setglobal(L, "p2number");

    luaL_dofile(L, "hook.lua");

    gum_interceptor_end_transaction (interceptor);

이제 실제 구현을 위해 사용자에게 제공할 여러 함수를 정의합니다. luaL_dofile가 호출되면 사용자가 작성한 Lua 스크립트를 파싱하여 사용자가 요구한 함수들을 후킹하게 됩니다.

typedef struct _HookChain
{
    int id;
    int argc;
    char *fn;
    char *newfn;
    char *orifn;
} HookChain, *LpHookChain;
HookChain hc[100];

static int lua_replace_attach(lua_State *L)
{
    //printf("lua_replace_attach:: called\n");
    const char *fn = luaL_checkstring (L, 1);
    int argc = luaL_checknumber (L, 2);
    const char *newfn = luaL_checkstring (L, 3);
    const char *orifn = luaL_checkstring (L, 4);

    hc[g_count].id = g_count;
    hc[g_count].argc = argc;
    string_copy(&hc[g_count].fn, fn);
    string_copy(&hc[g_count].newfn, newfn);
    string_copy(&hc[g_count].orifn, orifn);

    lua_pushcfunction(L, (lua_CFunction)original_function_handler);
    lua_setglobal(L, orifn);

    gum_interceptor_replace (interceptor,
            (gpointer) gum_module_find_export_by_name (NULL, fn), hook_handler, GSIZE_TO_POINTER(&hc[g_count]));

    lua_pushinteger(L, 0);
    g_count++;
    return 1;
}

Lua에서 hook함수를 호출하면 C 함수 lua_interceptor_attach가 호출되는데 총 4개의 인자를 받아서 처리합니다.

1번째 인자는 후킹할 함수의 exported된 이름인데 gum_module_find_export_by_name로 함수의 포인터로 전환되고 gum_interceptor_replace를 통해 후킹되게 됩니다.

2~4번째 인자는 각각 함수 인자의 갯수, 후킹되었을때 호출될 Lua 함수명, 원본함수로 쓸 Lua 함수명입니다.

후킹된 모든 함수는 공통적으로 hook_handler로 넘겨지고 1~4번째 인자 정보를 담은 데이터를 HookChain 구조체로 전달합니다.

추가적으로 Lua 스크립트에서 원본 함수를 호출할 수 있도록 별도의 함수(original_function_handler)를 정의해줍니다.

long hook_handler()
{
    GumInvocationContext *ic = gum_interceptor_get_current_invocation();
    LpHookChain hc = (LpHookChain)gum_invocation_context_get_replacement_data(ic);
    //printf("hook_handler:: id=%d func=%s\n", hc->id, hc->fn);

    lua_getglobal(L, hc->newfn);
    for (int i = 0; i < hc->argc; i++) {
        lua_pushlightuserdata(L, gum_invocation_context_get_nth_argument (ic, i));
    }
    lua_pcall(L, hc->argc, 1, 0);

    long ret_value = lua_tointeger(L, -1);
    lua_pop(L, 1);

    return ret_value;
}

후킹된 함수가 호출되었을때 hook_handler 함수가 실행되고 gum_invocation_context_get_replacement_data로 아까 저장했던 인자 데이터를 다시 가져옵니다.

두번째 인자로 전달받은 함수 인자 갯수(hc->argc)만큼 Lua인자를 push해주는데 인자가 string 타입인지 long 타입인지 정보를 알 수 없으므로 사용자 데이터 형식으로 인자를 전달한다음 Lua 함수를 호출합니다.

여기서 Lua 함수는 new_open이 됩니다.

function printf(...)
    print(string.format(...))
end

function new_open(pathname, flag)
    ret = original_open(pathname, flag)

    printf("new_open(%s, %d) => %d", p2string(pathname), p2number(flag), ret)
    return ret
end
static int lua_pointer_to_string(lua_State *L) //p2string
{
    const char *s = (const char *)lua_touserdata (L, 1);
    lua_pushstring(L, s);
    return 1;
}

static int lua_pointer_to_number(lua_State *L) //p2number
{
    long s = (long)lua_touserdata (L, 1);
    lua_pushinteger(L, s);
    return 1;
}

Lua 함수 new_open에서 pathname과 flag는 사용자 데이터이므로 이를 가공하고 출력하려면 Lua 형식으로 데이터를 변환할 필요성이 있습니다. 본문에서는 p2string, p2number와 같은 함수를 제공해주는 방식으로 해결하였습니다.

original_xxx 원본 함수 호출에 대한 구현은 다음의 방법으로 진행하였습니다.

LpHookChain find_by_original_function(const char *orifn)
{
    if (!orifn) {
        return NULL;
    }
    for (int i = 0; i < g_count; i++) {
        if (!strcmp(hc[i].orifn, orifn)) {
            return &hc[i];
        }
    }
    return NULL;
}

static long original_function_handler(lua_State *L)
{
    lua_Debug ar;
    lua_getstack(L, 0, &ar);
    lua_getinfo(L, "nSl", &ar);
    //printf("original_function_handler:: %s\n", ar.name);

    LpHookChain hc = find_by_original_function(ar.name);
    gpointer fnpointer = (gpointer)gum_module_find_export_by_name(NULL, hc->fn);

    typedef union {
        int integer;
        double number;
        const char *string;
    } variant;

    int argc = lua_gettop(L);
    variant *argv = malloc(argc * sizeof(variant));

    ffi_cif cif;
    ffi_type **types = malloc(argc * sizeof(ffi_type *));
    void **values = malloc(argc * sizeof(void *));

    for (int i = 0; i < argc; ++i) {
        int j = i + 1, typ = lua_type(L, j);
        switch (typ) {

        case LUA_TNUMBER:
            if (lua_isinteger(L, j)) {
                types[i] = &ffi_type_sint;
                argv[i].integer = lua_tointeger(L, j);
                values[i] = &argv[i].integer;
            } else {
                types[i] = &ffi_type_double;
                argv[i].number = lua_tonumber(L, j);
                values[i] = &argv[i].number;
            }
            break;

        case LUA_TSTRING:
            types[i] = &ffi_type_pointer;
            argv[i].string = lua_tostring(L, j);
            values[i] = &argv[i].string;
            break;

        case LUA_TLIGHTUSERDATA:
            types[i] = &ffi_type_pointer;
            argv[i].string = lua_touserdata(L, j);
            values[i] = &argv[i].string;
            break;

        default:
            printf("Unhandled argment type=%d\n", typ);
            abort();
            break;
        }
    }

    int result = -1;
    if (ffi_prep_cif(&cif, FFI_DEFAULT_ABI, argc, &ffi_type_sint, types) == FFI_OK) {
        ffi_call(&cif, (void (*)())fnpointer, &result, values);
    }

    free(values);
    free(types);
    free(argv);

    lua_pushinteger(L, result);
    return 1;
}

Lua에서 호출하는 original_xxx 원본 함수들은 모두 original_function_handler 함수로 호출됩니다.

함수 구분은 Lua 함수명을 통해 가능하고 find_by_original_function을 통해 HookChain 구조체 정보를 가져올 수 있습니다.

Lua로부터 전달받은 인자의 속성이 LUA_TLIGHTUSERDATA이면 lua_touserdata로 변환해 그대로 원본 함수로 넘겨주고 long형이나 문자열 같은 데이터로 확인되면 C 함수로 호출이 가능하도록 적절한 타입으로 변환합니다.

마지막으로 HookChain 구조체를 통해 획득한 원래 함수에 대한 포인터와 인자 정보로 함수를 동적으로 호출해야 하는 문제를 ffi 라이브러리를 사용하여 해결하였습니다.

4. 실행 / 테스트


function printf(...)
    print(string.format(...))
end

function new_open(pathname, flag)
    ret = original_open(pathname, flag)

    printf("new_open(%s, %d) => %d", p2string(pathname), p2number(flag), ret)
    return ret
end

function new_read(fd, buf, size)
    new_fd = original_open("/etc/hosts", 0)
    ret = original_read(new_fd, buf, size)

    printf("new_read(%d, %s, %d) => %d", new_fd, p2string(buf), p2number(size), ret)
    return ret
end

hook("open", 2, "new_open", "original_open")
hook("read", 3, "new_read", "original_read")

위의 작성된 스크립트를 실행하면 read하는 모든 내용을 /etc/hosts파일의 내용으로 조작하여 반환할 수 있습니다.

후킹 후 open, read 함수 호출

int fd = open("/etc/passwd", 0);
char buf[200];
printf("[%d]\n", read(fd, buf, 100));
printf("buf => [%s]\n", buf);

/assets/2020-05-01/lua2.png

실행결과, /etc/passwd 내용 대신 /etc/hosts로 조작되는 것을 확인할 수 있습니다.

5. 마치며


Lua가 개발자 입장에서는 C/C++에 이식하기 편하고 가벼워 활용하기에는 좋았지만 사용자 입장에서는 생소한 문법일 수 있고 JS나 Python같이 기본적인 라이브러리를 많이 지원하지않는 것에 대해서는 아쉬움이 있었습니다.

Embedded JS나 Python으로 위와 비슷한 기능을 구현한다거나 Lua로 사용자를 위한 스크립트 기능을 구현해야할 일이 있다면 해당 문서를 참고하면 좋을 것 같습니다.