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

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

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

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

도구 디렉토리

카테고리

모든 카테고리 보기
Loading categories
DeathSleep — 실행이 없는 동안 페이지 보호 변경을 구현하면서, 현재 스레드를 종료하고 실행 재개 전에 복원하는 회피 기법에 대한 PoC 구현입니다. | Kitploit
도구/GitHubGitHub/janoglezcampos/deathsleep
ExploitationMalware AnalysisRed TeamingPayload Development
GitHubjanoglezcampos/deathsleep

DeathSleep

실행이 없는 동안 페이지 보호 변경을 구현하면서, 현재 스레드를 종료하고 실행 재개 전에 복원하는 회피 기법에 대한 PoC 구현입니다.

저장소 보기
538774년 전Kitploit 검토 완료

인기

모두 보기 →

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

모든 도구 탐색

도구 컬렉션을 둘러보세요

모든 도구 보기 →
공유

██████╗ ███████╗ █████╗ ████████╗██╗ ██╗███████╗██╗ ███████╗███████╗██████╗ ██╔══██╗██╔════╝██╔══██╗╚══██╔══╝██║ ██║██╔════╝██║ ██╔════╝██╔════╝██╔══██╗ ██║ ██║█████╗ ███████║ ██║ ███████║███████╗██║ █████╗ █████╗ ██████╔╝ ██║ ██║██╔══╝ ██╔══██║ ██║ ██╔══██║╚════██║██║ ██╔══╝ ██╔══╝ ██╔═══╝ ██████╔╝███████╗██║ ██║ ██║ ██║ ██║███████║███████╗███████╗███████╗██║
╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝╚══════╝╚═╝

현재 스레드를 종료하고 실행을 재개하기 전에 복원하는 회피 기술에 대한 PoC 구현으로, 실행이 없는 동안 페이지 보호 변경을 구현합니다.

소개

슬립 및 난독화 방법은 maldev 커뮤니티에서 잘 알려져 있으며, 다양한 구현 방식으로 메모리 스캐너로부터 슬립 중에 숨는 목적을 가지고 있습니다. 일반적으로 페이지 보호를 변경하고 쉘코드를 암호화하는 멋진 기능까지 추가하지만, 쉘코드를 숨기는 또 다른 중요한 요소가 있습니다. 바로 현재 실행 스레드를 숨기는 것입니다. 스택을 스푸핑하는 것은 멋지지만, 조금 더 생각해본 결과 스택이 없다면 스택을 스푸핑할 필요가 없다고 생각했습니다 :)

이 기술의 유용성은 독자가 평가할 몫이지만, 어쨌든 몇 가지 주제를 복습하고 저처럼 이 세계에 막 입문하는 사람들을 위해 maldev를 배울 수 있는 멋진 방법이라고 생각합니다.

여기에 제시된 주요 구현은 스택에서 빼내야 할 모든 것을 데이터 섹션에 전역 변수로 보관하지만, 곧 모든 것을 힙으로 이동하는 구현이 게시될 예정입니다. 이는 이 코드를 PIC(위치 독립 코드)로 만들고 인젝션 가능하게 만드는 데 필요한 몇 가지 주요 수정 사항을 보여주는 것을 목표로 합니다.

이 저장소는 GitHub와 GitLab 간에 미러링되어 있습니다.


무슨 일이 일어나고 있나요?

먼저

여기에 명시된 모든 내용은 읽거나 개발 경험을 통해 다룬 다양한 주제에 대한 제 이해에서 비롯된 것입니다. 저는 전문가가 아니며 가장 원하지 않는 것은 잘못된 정보를 퍼뜨리는 것임을 잘 알고 있습니다. 따라서 내용 중 올바르지 않은 부분이 있다면 트위터를 통해 알려주시거나 이 저장소에 이슈를 열어주시면 감사하겠습니다. 이해해 주셔서 감사합니다. :)

기본 사항

이 기술의 주요 목표는 분명합니다: 현재 스레드를 종료하고 실행을 재개하기 전에 복원하는 것입니다. 그런데 이것이 정확히 무엇을 의미하며, 어떤 새로운 제약 조건을 만드는 것일까요?

실행을 복원하려면 스레드를 종료하기 전에 두 가지를 저장해야 합니다. 첫째는 CPU 상태, 둘째는 스택입니다. 그리고 새 스레드가 시작된 후에 이들을 효과적으로 다시 설정해야 합니다.

이 기술에서 나타날 새로운 제약 조건에 대해 이야기했는데, 두 가지 주요한 것이 있습니다: 첫째, 스레드가 종료된 시점부터 스택이 복원될 때까지 필요한 모든 것을 스택 외부에 저장해야 하며, 보시다시피 새로운 도전 과제를 만듭니다.

둘째, 우리 프로세스에는 항상 최소한 하나 이상의 다른 스레드가 실행 중이어야 합니다. 스레드를 종료하고 있기 때문에 다른 스레드가 없으면 프로세스가 종료되기 때문입니다. 대부분의 에이전트가 다른 프로세스에 인젝션되므로 이 프로세스에는 적어도 하나의 스레드가 계속 실행 중일 것이라고 가정할 수 있어 큰 문제는 아니라고 생각합니다.

DeathSleep 구성 요소:

이 POC에서 4가지 핵심 함수를 볼 수 있습니다:

  • 메인 프로그램: 에이전트 코드를 작성할 곳이며, DeathSleep을 사용하는 코드 부분입니다.
  • Awake 함수: 모든 스레드의 진입점이며, 복원할 스택의 시작점을 저장하는 역할을 합니다. 또한 필요할 때 스택과 CPU 컨텍스트를 복원하거나 메인 프로그램을 시작하는 역할을 합니다.
  • DeathSleep: 이 기술의 주요 함수로, 스레드 컨텍스트와 스택을 백업하고 마법이 일어나도록 모든 것을 설정하는 역할을 합니다.
  • Rebirth: 새 스레드를 시작하기 위한 간단한 함수입니다.

스택 저장.

스택을 저장하려 할 때 질문이 생깁니다: 스택의 어느 정도를 저장해야 할까요?

DeathSleep 함수를 호출한 후 스택에 무엇이 있는지 먼저 살펴보겠습니다. (이 함수는 컨텍스트, 스택을 저장하고 난독화 및 복원을 위한 모든 것을 준비합니다)

보시다시피, 모든 함수는 세 부분으로 구성됩니다:

  • 섀도우 스페이스: 호출자가 할당하지만 호출된 함수가 사용하는 32바이트 공간입니다. 제가 알고 관찰한 바로는, 주요 기능은 필요할 경우 레지스터에 전달된 인수를 보관하는 것이지만, 호출된 함수가 결정하는 대로 무엇이든 사용할 수 있습니다.
  • 반환 주소: CALL 명령어에 의해 푸시된, 호출자 함수에서 실행할 다음 명령어의 주소입니다. 따라서 호출된 함수의 RET 명령어는 이 주소를 가져와 종료 시 "점프"합니다.
  • 함수 스택 공간: 호출된 함수가 복원해야 할 레지스터 값과 지역 변수 값을 저장하기 위해 예약한 공간입니다.

저장해야 할 최소한의 스택 부분은 메인 프로그램 내부의 모든 것입니다. 즉, 메인 프로그램의 섀도우 스페이스, 반환 주소, 그리고 DeathSleep 함수까지의 모든 것입니다. 그 이전의 것은 실제로 필요하지 않습니다 (진입 함수가 사용한 스택을 저장하는 것도 장점이 있지만, 나중에 논의하겠습니다). 그 이전은 Windows 루틴이 새 스레드를 시작하는 데 사용하는 스택이기 때문입니다. 이 외에도 DeathSleep 함수의 섀도우 스페이스도 저장하기로 결정했습니다 (실제로 필요하지는 않지만, 깨어날 때 Rsp 계산을 더 쉽게 만듭니다).

따라서 최종적으로 저장하는 것은 다음과 같습니다:

스택 주소를 찾는 방법:

표준 컴파일에서 각 함수는 프롤로그, 함수 코드, 에필로그의 세 부분으로 구성되어야 합니다.

Rsp (스택 포인터)는 함수 프롤로그와 에필로그에서만 수정되어야 합니다. 프롤로그는 스택 포인터를 증가시키고 (스택 증가는 주소 감소를 의미합니다, 반대 방향이기 때문에), 레지스터를 저장하고 모든 지역 변수를 보관한 다음 섀도우 스페이스를 보관합니다. 에필로그는 정반대 작업을 수행합니다.

즉, 함수 코드 내부의 스택 포인터는 항상 섀도우 스페이스의 끝을 가리켜야 하며 (위 이미지에서 보라색), 함수의 스택 크기와 섀도우 스페이스의 합은 프롤로그가 스택 포인터를 얼마나 증가시키는지 계산하여 찾을 수 있습니다. 이 값은 언와인드 테이블에 있는 정보를 사용하여 쉽게 계산할 수 있습니다. 이 테이블의 사용법에 대한 설명은 여기서 다루지 않지만, 요약하자면 이 테이블은 다른 스레드나 프로세스가 스택을 올바르게 이동하여 내용을 보거나, 예외를 처리하거나, 분석할 수 있도록 하는 데 사용됩니다.

복원할 컨텍스트 캡처 및 준비.

컨텍스트를 캡처하는 것은 아마도 가장 쉬운 작업 중 하나입니다. DeathSleep의 첫 번째 줄에서, 비휘발성 레지스터가 수정되기 전에 간단히 RtlCaptureContext()를 호출하면 되기 때문입니다. 그러나 실행을 복원할 컨텍스트에 두 가지 수정을 가해야 합니다.

첫 번째는 Rip을 수정하는 것입니다. 기억하시겠지만, Rip은 다음에 실행할 명령어를 보관하는 레지스터입니다. 이 값을 수정하지 않으면 실행이 DeathSleep 함수 내에서 재개됩니다. 우리가 할 일은 Rip을 DeathSleep의 반환 주소로 변경하는 것입니다. 이 주소는 현재 Rsp가 가리키는 위치에서 에필로그에서 증가된 크기만큼 이동한 곳입니다 (위 이미지에서 초록색 + 보라색 영역).

두 번째 수정은 스레드가 복원될 때 이루어지며, Rsp를 복원된 스택의 상단을 가리키도록 설정하는 것입니다. 새 스택이 어디에 배치될지 알 수 없기 때문에 복원 단계에서 수행됩니다. 값은 단순히 복원된 스택의 끝 주소입니다. 나중에 논의했듯이 DeathSleep 호출자가 예약한 섀도우 스페이스도 복사했기 때문에, 이 값은 DeathSleep 호출 전의 RSP 값과 정확히 일치합니다.

스택 복원:

깨어나는 지점에 도달하면 실행을 재개하기 직전에 저장된 스택을 제자리에 놓아야 합니다. 저장된 스택은 awake 함수가 캡처한 주소에서 시작된다는 것을 이미 알고 있으므로, 새로 캡처된 주소가 저장된 스택을 배치할 시작점이 됩니다. 그러나 여기에 문제가 있습니다. 이전 스택을 배치한 후 함수 호출이 이루어지면 스택이 수정되어 망가질 것입니다. 그리고 여기서 정리를 수행하는 것이 매우 편리합니다, 특히 스택 백업을 보관하는 데 사용된 힙을 해제하는 것이 그렇습니다. 즉, 현재 Rsp와 현재 사용 중인 스택 부분을 복원된 스택이 배치될 위치 밖으로 이동해야 합니다. 더 명확하게 하기 위해 문제를 설명하겠습니다:

그리고 제 해결책은 다음과 같습니다. 모든 것을 멀리 이동시키는 것입니다:

컨텍스트 복원:

모든 어려운 작업을 마친 후 마지막으로 할 일은 NtContinue를 사용하는 것입니다. 이 함수는 이전에 캡처하고 수정한 컨텍스트로 현재 컨텍스트를 변경하여 RIP가 DeathSleep 호출 직후로 설정되도록 하며, 모든 레지스터는 DeathSleep 호출 시와 동일한 값을 가져야 하고, RSP는 스택 상단을 가리켜야 합니다.

복원 프로세스 예약: Thread Pool API 사용.

좋습니다. 현재 스레드를 저장하고 복원하는 데 필요한 기본 사항을 알았습니다. 하지만 스레드가 없을 때도 이 모든 것을 실행할 수 있는 방법이 필요합니다. 여기서 Windows가 제공하는 툴인 Thread Pool API를 만나게 됩니다. 이 API는 운영 체제가 완전히 관리하는 스레드 그룹(풀)에 태스크(최대 하나의 인수를 가진 함수)를 큐에 넣을 수 있게 해줍니다. Ekko를 본 적이 있다면 이 API를 사용하는 것을 볼 수 있으므로, 같은 방식으로 구현해 봅시다.

모든 것이 잘 작동했지만 한 가지 문제가 있었습니다. 큐에 넣은 태스크의 실행이 끝난 후에도 워커가 여전히 살아 있었습니다. 이는 프로그램이 생성할 수 있는 모든 스레드를 제거하고 싶었기 때문에 문제가 되었습니다. 그래서 이 방법은 적합하지 않았습니다.

조금 더 조사한 결과, Ekko에서 사용된 Thread Pool API는 오래된 버전이었고, 추가 기능을 가진 새로운 버전이 있다는 것을 발견했습니다. 그중에 우리 문제를 효과적으로 해결할 함수인 CloseThreadPool()이 있었습니다. 이 새로운 API는 자체 풀을 생성하고 사용 후 풀을 파괴하여 사용된 모든 워커를 종료할 수 있게 해줍니다. 또한 두 가지 이점을 더 제공합니다: 최대 스레드 수 설정 및 정리 그룹. 최대 스레드 수를 설정하면 시간 차이를 두고 큐에 넣는 한 모든 태스크를 순차적으로 실행할 수 있습니다. 정리 그룹은 모든 작업이 완료된 후 정리를 더 쉽게 만드는 데 유용합니다.

그래서... 모든 것이 끝난 걸까요? 음, 이 시점에서 스레드는 종료되었고, awake를 진입점으로 하는 새 스레드를 생성하고 이전 상태를 복원한 후 풀을 닫는 rebirth 함수를 큐에 넣고 있습니다. 지금까지는 좋습니다!

메모리 권한 변경, 실행 리디렉션.

앞서 논의한 모든 것을 마쳤을 때, 저는 어려운 부분이 해결되었다고 생각했습니다. 이 부분은 이전 기술들에서 이미 해결되었기 때문입니다. 하지만, 오 보이, 무슨 일이 기다리고 있는지 몰랐습니다.

주요 문제는 메모리 보호를 RW(읽기-쓰기)로 변경하고 있기 때문에 이 작업을 코드 외부로 오프로드해야 한다는 것입니다. VirtualProtect()를 호출하면 함수가 반환될 때 프로세스가 충돌합니다 (RW 페이지에서 명령어를 실행할 수 없습니다). 따라서 다른 곳에서 이 작업을 실행하고, 또한 RX(읽기-실행) 페이지로 반환되도록 해야 합니다 (돌아올 때도 같은 문제가 발생합니다). 분명히 이를 위해 Thread Pool API를 사용할 것이지만, 한 가지 문제가 있습니다. 태스크에 하나의 인수만 전달할 수 있는데, VirtualProtect()는 4개의 인수를 받습니다.

이를 위해 NtContinue()를 다시 사용할 것입니다. 이 함수의 이러한 사용법을 처음 본 것은 Foliage에서였지만, Ekko에서도 사용됩니다. 앞서 보았듯이 NtContinue()는 호출하는 스레드에 일부 컨텍스트를 설정할 수 있게 해주며, 약간의 영리한 조작을 통해 하나의 인수만 사용하여 여러 인수를 가진 함수를 "호출"할 수 있습니다 (Thread Pool API에 매우 편리합니다). 주요 아이디어는 RIP를 함수의 시작 주소로 설정하는 것이며, Windows x64 호출 규칙은 처음 네 개의 인수를 레지스터(rcx, rdx, r8, r9 순서)로 전달하므로, NtContinue에 전달할 컨텍스트 구조에 인수를 넣기만 하면 함수 호출을 효과적으로 시뮬레이션할 수 있습니다. NtContinue를 사용할 때 마지막으로 주의해야 할 점은 Rsp입니다. 앞서 보았듯이 이 주소는 함수가 호출될 때 반환 주소를 보유해야 합니다.

따라서 NtContinue가 작동하려면 먼저 컨텍스트가 필요합니다. 수동으로 만들 수도 있지만 한 가지 문제가 있습니다. Rsp 값을 찾는 것입니다. 이 값은 함수에 전달될 때 RET가 반환하는 데 사용할 주소를 가리켜야 합니다. 태스크는 다른 스레드에서 작동하므로 스택이 어디에 배치될지 알 수 없습니다. 해결책은 (Ekko에서 신중하게 훔친 것입니다. 정말 감사합니다 :P) RtlCaptureContext()를 사용하여 워커 내에서 컨텍스트의 복사본을 가져온 다음, 얻은 컨텍스트의 스택 포인터를 8만큼 증가시키는 것입니다. 그러면 CALL RtlCaptureContext()에 의해 스택에 삽입된 주소를 가리키게 되며, 이는 이 마지막 함수의 반환 주소이며, 모든 함수의 반환 주소로 사용할 수 있습니다.

좋습니다, 그런데 Rsp에 이 수정을 할 수 없을 때는 어떻게 될까요? 이는 난독화를 해제할 때 발생합니다. 새 스레드에 있게 되므로 이전 컨텍스트의 Rsp는 쓸모가 없습니다. 새 스레드에서 가져온 새 컨텍스트가 필요하지만, Rsp를 올바른 주소로 가리키도록 수정하는 이전 트릭을 사용할 수 없습니다.

Rop 체인

따라서 얻은 컨텍스트를 수정할 수는 없지만, 그것이 쓸모없다는 의미는 아닙니다. 실제로 다르게 사용할 것입니다. Rip을 수정하지 않고 NtContinue()로 해당 컨텍스트를 복원하면 RtlCaptureContext() 호출 다음 명령어로 실행을 리디렉션하고 올바른 Rsp를 가지게 되므로, 수정된 컨텍스트로 NtContinue()를 호출한 후에 이를 사용하여 태스크 실행을 올바르게 종료할 수 있습니다. 이를 위해 Rop 체인을 사용할 것입니다. 첫 번째 컨텍스트의 Rsp를 수동으로 제작된 스택을 가리키도록 설정하여, 두 번째 NtContinue() 호출까지 실행을 리디렉션하는 데 필요한 모든 것을 보관하게 합니다. 두 번째 호출은 종료할 올바른 컨텍스트를 설정합니다.

수작업 스택은 다음과 같아야 합니다:

두 개의 rop 가젯을 사용합니다. 하나는 함수의 섀도우 스페이스를 수정하거나 "점프"하기 위한 것이고, 두 번째는 NtContinue의 인수를 rcx에 배치한 다음 반환하는 역할을 합니다.

이 두 rop 가젯을 찾는 것은 매우 쉽습니다. 섀도우 스페이스를 수정하는 가젯은 거의 모든 함수의 에필로그입니다 (Ntdll에서만 500개 이상의 히트를 찾았습니다). 앞서 보았듯이 에필로그는 주로 Rsp를 줄이기 위해 설계되었습니다. 두 번째 가젯은 pop rcx; ret;이며 2바이트이고, Ntdll과 Kernel32 dll 사이에서 몇 개 발견되었습니다.

Thread Pool Api에 대한 약간의 리버싱

앞서 보았듯이 NtContinue를 사용하려면 첫 번째 인수만 채워지면 작동합니다. 이는 오래된 Thread Pool API에 완벽하게 적합합니다. 그러나 새로운 Thread Pool API에서는 인수가 두 번째 위치에 전달되므로, 이것만으로는 작동하지 않습니다.

몇 시간 동안 이 마지막 문제를 해결하는 방법을 알지 못하다가, 두 API가 어떤 경우에는 동일한 함수를 사용한다는 생각이 들었고, 이로 인해 그들이 보이는 것보다 더 유사할 수 있다고 생각했습니다. 그래서 그들 사이의 관계가 무엇인지 조사하기로 결정했습니다.

오래된 API에서는 태스크를 큐에 넣기 위해 CreateTimerQueueTimer()를 사용합니다. 새로운 API에서는 동일한 작업을 위해 두 개의 함수가 필요합니다: CreateThreadpoolTimer()는 콜백 함수와 전달할 인수를 받고, 태스크를 설명하는 TP_TIMER 구조체에 대한 포인터를 반환합니다. 그리고 태스크를 큐에 넣는 두 번째 함수: SetThreadpoolTimer()는 이전 포인터와 태스크가 실행될 시간을 설명하는 FILETIME 구조체 포인터를 받습니다.

이 함수들을 리버싱하면 다음을 찾을 수 있습니다:

보시다시피 CreateThreadpoolTimer()는 TpAllocTimer()에 대한 멋진 래퍼일 뿐이며, SetThreadpoolTimer()는 TpSetTimer()로의 포워더일 뿐입니다.

이제 CreateTimerQueueTimer()의 내부를 확인해 봅시다. 먼저, 이는 Ntdll의 함수 RtlCreateTimer()에 대한 또 다른 멋진 래퍼일 뿐입니다. 그리고 여기에 마법이 있습니다. 이는 더 큰 함수이지만, 우리가 찾던 핵심은 다음과 같습니다:

보시다시피 이 함수 내부에는 실제로 TpAllocTimer()와 TpSetTimer()에 대한 호출이 있습니다. 즉, 내부에서 CreateThreadpoolTimer()와 SetThreadpoolTimer()를 호출하는 것과 유사합니다. 보시다시피 우리가 큐에 넣는 함수는 함수에 제공한 콜백이 직접적으로 아니라 RtlpTpTimerCallback()을 콜백으로 설정하고 있습니다. 아직 이것이 무엇을 의미하는지 깨닫지 못했다면, 우리는 CreateThreadpoolTimer()를 사용하여 두 번째 위치에서 인수를 받는 함수 RtlpTpTimerCallback()을 큐에 넣고 있으며, 이 함수는 첫 번째 위치에서 인수를 가진 다른 함수를 실행할 것임을 의미합니다.

따라서 우리가 여전히 이해해야 할 유일한 것은 콜백 정보가 RtlpTpTimerCallback()에 어떻게 전달되는지이며, 몇 시간의 리버싱 끝에 다음과 같은 구조체를 발견했습니다. 놀랍게도 작동합니다!

이제 첫 번째 위치에서 인수를 받는 함수를 호출할 수 있으며, 동시에 풀을 닫고 실행 중인 스레드를 남기지 않을 수 있습니다. 윈-윈입니다. 이 함수는 Ntdll에서 내보내지지 않으므로 DLL 내에서 바이트 형태로 찾기로 결정했습니다.

이것이 끝입니다. 검토한 모든 것을 통해, 이 POC를 개발하는 동안 제 머리 속에 떠오른 핵심 아이디어와 모든 것을 제가 한 방식으로 수행한 이유를 제시했다고 생각합니다.

즐기셨길 바랍니다 :)


테스트 고려 사항

이 코드는 MSCV 컴파일러 및 링커에서만 테스트되었습니다. 이 POC는 컴파일 방식에 크게 의존하므로 동일한 도구를 사용하는 것을 권장하며, 다른 컴파일러에서 즉시 작동할 것이라고 보장하지 않습니다.

도구 다운로드