Skip to content
KitploitKITPLOIT
도구익스플로잇블로그
Log in
제출
도구익스플로잇블로그
제출

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

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

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

도구 디렉토리

카테고리

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

DeathSleep

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

저장소 보기
5387784년 전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입니다. 앞서 보았듯이 이 주소는 함수가 호출될 때 반환 주소를 보유해야 합니다.

도구 다운로드