서론
바이너리 보호 기법 개념 자체가 없을 때에는 셸코드를 통해 다양한 시스템 콜을 호출해 임의 명령어를 실행했음. 해당 기법을 이용한 악성 코드의 위협이 계속되자 셸코드의 실행을 방지하기 위해 NX 보호 기법이 등장했다. 그러나 얼마가지 않아 셸코드를 이요하지 않고 라이브러리의 함수를 호출해 보호 기법을 우회하는 Return-To-Libc(RTL)이 등장했고, 프로그램의 코드를 재활용해 임의 함수를 연속해서 호출할 수 있는 Return-Oriented Programming(ROP) 기법 또한 등장했다.
시그널
SROP에 대해서 알아보기 전에, 시그널(Signal)을 처리하는 방식을 이해하고 있어야 한다. 운영 체제는 크게 유저 모드(User Mode)와 커널 모드(Kernel Mode)로 나뉘어진다. 실제로 파일을 생성하고, 프로그램을 실행하는 모든 작업은 이 두 개의 모드가 서로 상호 작용하면서 이뤄진다.
시그널은 프로세스에 특정 정보를 전달하는 매개체로, 우리가 프로그램을 공격하면서 보았던 SIGSEGV 또한 시그널에 포함된다.
시그널이 발생하면 시그널에 해당하는 커널 모드에서 실행되고, 다시 유저 모드로 복귀한다.
시그널 동작 방식
// Name: sig_alarm.c
// Compile: gcc -o sig_alarm sig_alarm.c
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<stdlib.h>
void sig_handler(int signum){
printf("sig_handler called.\n");
exit(0);
}
int main(){
signal(SIGALRM,sig_handler);
alarm(5);
getchar();
return 0;
}
위 코드는 5초 후에 sig_handler를 호출하는 예제 코드이다.
코드를 살펴보면, signal 함수를 사용해 SIGALRM 시그널이 발생하면 sig_handler 함수를 실행한다.
프로세스에서 이 모든 것을 처리하는 것처럼 보일 수 있으나, SIGALRM 시그널이 발생하면 커널 모드로 진입한다.
여기서 시그널을 커널 모드에서 처리하고 나서 다시 유저 모드로 돌아와 프로세스의 코드를 실행해야 한다.
이 말은, 유저 모드의 상태를 모두 기억하고 되돌아올 수 있어야 한다는 것이다.
커널에서는 유저 모드로 되돌아가야 하는 상황을 고려해 유저 프로세스의 상태를 저장하는 코드가 구현되어 있다.
do_signal
do_signal 함수는 시그널을 처리하기 위해 제일 먼저 호출되는 함수이다.
아래 코드는 arch_do_signal_or_restart 함수로, 시그널이 발생했다면 시그널에 대한 정보를 인자로 get_signal 함수를 호출한다. 해당 함수에서는 시그널에 해당하는 핸들러가 등록되어 있는지 확인한다.
만약 핸들러가 등록되어 있다면 시그널에 대한 정보와 레지스터 정보를 인자로 handle_signal 함수를 호출한다.
void arch_do_signal_or_restart(struct pt_regs *regs, bool has_signal)
{
struct ksignal ksig;
if (has_signal && get_signal(&ksig)) {
/* Whee! Actually deliver the signal. */
handle_signal(&ksig, regs);
return;
}
/* Did we come from a system call? */
if (syscall_get_nr(current, regs) >= 0) {
/* Restart the system call - no handlers present */
switch (syscall_get_error(current, regs)) {
case -ERESTARTNOHAND:
case -ERESTARTSYS:
case -ERESTARTNOINTR:
regs->ax = regs->orig_ax;
regs->ip -= 2;
break;
case -ERESTART_RESTARTBLOCK:
regs->ax = get_nr_restart_syscall(regs);
regs->ip -= 2;
break;
}
}
/*
* If there's no signal to deliver, we just put the saved sigmask
* back.
*/
restore_saved_sigmask();
}
handle_signal
아래 코드는 handle_signal 함수의 일부로, setup_rt_frame 함수를 호출하는 것을 볼 수 있다.
static void
handle_signal(struct ksignal *ksig, struct pt_regs *regs)
{
...
failed = (setup_rt_frame(ksig, regs) < 0);
if (!failed) {
fpu__clear_user_states(fpu);
}
signal_setup_done(failed, ksig, stepping);
}
이 함수는 아래 코드와 같이 시그널에 적용된 핸들러가 존재할 경우, 핸들러의 주소를 다음 실행 주소로 삽입한다. 앞서 예제에서는 SIGALRM이 발생할 경우 해당 코드를 통해 sig_handler 함수가 호출된다.
regs->si = (unsigned long)&frame->info;
regs->dx = (unsigned long)&frame->uc;
regs->ip = (unsigned long) ksig->ka.sa.sa_handler;
regs->sp = (unsigned long)frame;
sigreturn
현재 프로세스가 바뀌는 것을 컨텍스트 스위칭(Context Switching)이라고 한다. 앞서 분석해본 것처럼 커널은 유저가 실행한 프로세스를 관리하기 위해 다양한 코드를 실행하는데, 컨텍스트 스위칭이 일어나면 다시 유저 프로세스로 복귀해야 한다. 따라서 스위칭이 일어날 때의 상황을 커널에서 기억하고, 커널 코드의 실행을 마치면 기억한 정보를 되돌려 복귀해야 하는데, 이 때 사용되는 시스템 콜이 sigreturn이다.
아래 코드는 restore_sigcontext 함수의 코드로, sigreturn 시스템 콜을 호출하면 내부적으로 해당 함수를 호출해 스택에 저장된 값을 각각의 레지스터에 복사하여 기존 상황과 실행할 코드를 기억하고 복귀한다.
코드를 살펴보면, sigcontext 구조체에 존재하는 각 멤버 변수에 값을 삽입하는 것을 볼 수 있다.
static bool restore_sigcontext(struct pt_regs *regs,
struct sigcontext __user *usc,
unsigned long uc_flags)
{
struct sigcontext sc;
/* Always make any pending restarted system calls return -EINTR */
current->restart_block.fn = do_no_restart_syscall;
if (copy_from_user(&sc, usc, CONTEXT_COPY_SIZE))
return false;
#ifdef CONFIG_X86_32
set_user_gs(regs, sc.gs);
regs->fs = sc.fs;
regs->es = sc.es;
regs->ds = sc.ds;
#endif /* CONFIG_X86_32 */
regs->bx = sc.bx;
regs->cx = sc.cx;
regs->dx = sc.dx;
regs->si = sc.si;
regs->di = sc.di;
regs->bp = sc.bp;
regs->ax = sc.ax;
regs->sp = sc.sp;
regs->ip = sc.ip;
#ifdef CONFIG_X86_64
regs->r8 = sc.r8;
regs->r9 = sc.r9;
regs->r10 = sc.r10;
regs->r11 = sc.r11;
regs->r12 = sc.r12;
regs->r13 = sc.r13;
regs->r14 = sc.r14;
regs->r15 = sc.r15;
#endif /* CONFIG_X86_64 */
/* Get CS/SS and force CPL3 */
regs->cs = sc.cs | 0x03;
regs->ss = sc.ss | 0x03;
regs->flags = (regs->flags & ~FIX_EFLAGS) | (sc.flags & FIX_EFLAGS);
/* disable syscall checks */
regs->orig_ax = -1;
#ifdef CONFIG_X86_64
/*
* Fix up SS if needed for the benefit of old DOSEMU and
* CRIU.
*/
if (unlikely(!(uc_flags & UC_STRICT_RESTORE_SS) && user_64bit_mode(regs)))
force_valid_ss(regs);
#endif
return fpu__restore_sig((void __user *)sc.fpstate,
IS_ENABLED(CONFIG_X86_32));
}
sigcontext
아래 구조체는 sigcontext 구조체로, 이를 살펴보면 레지스터 명칭을 가진 각각의 멤버 변수가 존재하는 것을 확인할 수 있다. 구조체의 멤버 변수 순서는 값 복사 순서와는 전혀 관계가 없으므로, 순서는 외우지 않아도 된다.
/* __x86_64__: */
struct sigcontext {
__u64 r8;
__u64 r9;
__u64 r10;
__u64 r11;
__u64 r12;
__u64 r13;
__u64 r14;
__u64 r15;
__u64 rdi;
__u64 rsi;
__u64 rbp;
__u64 rbx;
__u64 rdx;
__u64 rax;
__u64 rcx;
__u64 rsp;
__u64 rip;
__u64 eflags; /* RFLAGS */
__u16 cs;
__u16 gs;
__u16 fs;
union {
__u16 ss; /* If UC_SIGCONTEXT_SS */
__u16 __pad0; /* Alias name for old (!UC_SIGCONTEXT_SS) user-space */
};
__u64 err;
__u64 trapno;
__u64 oldmask;
__u64 cr2;
struct _fpstate __user *fpstate; /* Zero when no FPU context */
# ifdef __ILP32__
__u32 __fpstate_pad;
# endif
__u64 reserved1[8];
};
위에 있는 코드는 x86_64 아키텍처에 해당하는 구조체이다. 따라서 이외 아키텍처의 바이너리를 공격할 때에는 대상 아키텍처를 파악하고 해당 구조체와 커널 코드를 분석해서 진행해야 한다.
SROP
SigReturn-Oriented Programming (SROP)는 컨텍스트 스위칭을 위해 사용하는 sigreturn 시스템 콜을 이용한 ROP 기법이다. ROP는 기초 과정에서 배웠듯이 코드 조각을 모아서 임의의 코드를 실행하는 공격 기법인 것을 생각해보면 이는 sigreturn 시스템 콜을 호출하고, 레지스터에 복사할 값을 미리 스택에 저장해 임의 코드를 실행한다는 것을 알 수 있다.
아래 코드는 sigreturn 시스템 콜을 호출해 레지스터를 스택의 값으로 조작하는 예제 코드이다.
// Name: sigrt_call.c
// Compile: gcc -o sigrt_call sigrt_call.c
#include <string.h>
int main()
{
char buf[1024];
memset(buf, 0x41, sizeof(buf));
asm("mov $15, %rax;"
"syscall");
}
이를 실행하면 아래 사진과 같이 나온다.

'시스템해킹 > 개념' 카테고리의 다른 글
| Double Free bug와 Fastbin dup 복습 (0) | 2025.06.25 |
|---|---|
| [Dreamhack-system] Background : _IO_FILE (1) | 2024.12.20 |
| [Dreamhack-system] Background : _rtld_global (3) | 2024.12.17 |
| [Dreamhack-system] Background: Master Canary (2) | 2024.12.16 |
| [Dreamhack-system] Background: SECCOMP (4) | 2024.12.15 |