Repository: pwndbg/pwndbg

Issue:
Add command to kill threads
We killed all threads except current on a CTF recently with @alufers and he suggested we should have a command for that. (I think he started implementing it, so I am going to assign him too)

This command should:
* allow to kill `--all` or choosen threads
* should kill the threads via switching to those threads and calling `call pthread_exit(0)`
* should `set scheduler-locking on` before doing any action and revert the original value of this afterwards

Regarding the last point, we have a `with lock_scheduler():` in https://github.com/pwndbg/pwndbg/blob/dev/pwndbg/gdblib/scheduler.py#L7



Discussion:
Killing all threads is useful if one wants to use the GDB's `checkpoint` feature, e.g., to debug/test format string exploitation attempts :)

Resolving patch:
diff --git a/pwndbg/commands/__init__.py b/pwndbg/commands/__init__.py
--- a/pwndbg/commands/__init__.py
+++ b/pwndbg/commands/__init__.py
@@ -626,6 +626,7 @@ def load_commands() -> None:
     import pwndbg.commands.kchecksec
     import pwndbg.commands.kcmdline
     import pwndbg.commands.kconfig
+    import pwndbg.commands.killthreads
     import pwndbg.commands.kversion
     import pwndbg.commands.leakfind
     import pwndbg.commands.memoize
diff --git a/pwndbg/commands/killthreads.py b/pwndbg/commands/killthreads.py
new file mode 100644
--- /dev/null
+++ b/pwndbg/commands/killthreads.py
@@ -0,0 +1,74 @@
+import argparse
+from typing import List
+from typing import Optional
+
+import gdb
+
+import pwndbg.commands
+from pwndbg.color import message
+from pwndbg.commands import CommandCategory
+from pwndbg.gdblib.scheduler import lock_scheduler
+
+parser = argparse.ArgumentParser(
+    formatter_class=argparse.RawTextHelpFormatter,
+    description="""Kill all or given threads.
+
+Switches to given threads and calls pthread_exit(0) on them.
+This is performed with scheduler-locking to prevent other threads from operating at the same time.
+
+Killing all other threads may be useful to use GDB checkpoints, e.g., to test given input & restart the execution to the point of interest (checkpoint).
+""",
+)
+
+parser.add_argument("thread_ids", nargs="*", help="Thread IDs to kill.")
+parser.add_argument(
+    "-a",
+    "--all",
+    action="store_true",
+    help="Kill all threads except the current one.",
+)
+
+
+@pwndbg.commands.ArgparsedCommand(parser, category=CommandCategory.PROCESS)
+@pwndbg.commands.OnlyWhenRunning
+def killthreads(thread_ids: Optional[List] = None, all: bool = False) -> None:
+    if len(thread_ids) == 0 and not all:
+        print(message.error("No thread IDs or --all flag specified"))
+        return
+
+    if all and len(thread_ids) > 0:
+        print(message.error("Cannot specify thread IDs and --all"))
+        return
+
+    with lock_scheduler():
+        current_thread_id = gdb.selected_thread().num
+        available_thread_ids = [
+            thread.num
+            for thread in gdb.selected_inferior().threads()
+            if thread.num != current_thread_id
+        ]
+        if all:
+            thread_ids = available_thread_ids
+        else:
+            for thread_id in thread_ids:
+                if thread_id not in available_thread_ids:
+                    print(
+                        message.error(f"Thread ID {thread_id} does not exist, see `info threads`")
+                    )
+                    return
+        for thread_id in thread_ids:
+            gdb.execute(f"thread {thread_id}", to_string=True)
+            try:
+                gdb.execute("call (void) pthread_exit(0)", to_string=True)
+            except gdb.error as e:
+                # gdb will throw an error, because the thread dies during the call, which is expected
+                pass
+
+        # Switch back to the thread we were on before killing threads
+        gdb.execute(f"thread {current_thread_id}", to_string=True)
+        print(
+            message.success(
+                "Killed threads with IDs: "
+                + ", ".join([str(thread_id) for thread_id in thread_ids])
+            )
+        )


Test patch:
diff --git a/tests/gdb-tests/tests/binaries/makefile b/tests/gdb-tests/tests/binaries/makefile
--- a/tests/gdb-tests/tests/binaries/makefile
+++ b/tests/gdb-tests/tests/binaries/makefile
@@ -93,6 +93,9 @@ heap_malloc_chunk.out: heap_malloc_chunk.c
 	@echo "[+] Building heap_malloc_chunk.out"
 	${CC} -g -O0 -Wno-nonnull -Wno-unused-result -o heap_malloc_chunk.out heap_malloc_chunk.c -pthread -lpthread
 
+multiple_threads.out: multiple_threads.c
+	@echo "[+] Building multiple_threads.out"
+	${CC} -g -O0 -o multiple_threads.out multiple_threads.c -pthread -lpthread
 tls.x86-64.out: tls.x86-64.c
 	@echo "[+] Building tls.x86-64.c"
 	${ZIGCC} \
diff --git a/tests/gdb-tests/tests/binaries/multiple_threads.c b/tests/gdb-tests/tests/binaries/multiple_threads.c
new file mode 100644
--- /dev/null
+++ b/tests/gdb-tests/tests/binaries/multiple_threads.c
@@ -0,0 +1,59 @@
+#include <pthread.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <unistd.h>
+#include <semaphore.h>
+
+sem_t sem;
+
+void *thread_function(void *arg)
+{
+    sem_post(&sem);
+    usleep(4000);
+    exit(1);
+    return NULL;
+}
+
+void *useless_thread(void *arg)
+{
+    // signal the semaphore to indicate that the thread has started
+    sem_post(&sem);
+
+    while (1) {
+        usleep(1000);
+    }
+    return NULL;
+}
+
+void break_here()
+{
+
+}
+
+int main()
+{
+
+    // Initialize semaphore with count 0 to make sure both threads are started before calling break_here
+    sem_init(&sem, 0, 0);
+
+    pthread_t thread1, thread2;
+    int res1 = pthread_create(&thread1, NULL, thread_function, NULL);
+    if (res1 != 0) {
+        perror("Thread creation failed");
+        exit(EXIT_FAILURE);
+    }
+    int res2 = pthread_create(&thread2, NULL, useless_thread, NULL);
+    if (res2 != 0) {
+        perror("Thread creation failed");
+        exit(EXIT_FAILURE);
+    }
+
+    // Wait for both threads to start
+    sem_wait(&sem);
+    sem_wait(&sem);
+
+    break_here();
+    usleep(8000);
+    
+    return 0;
+}
diff --git a/tests/gdb-tests/tests/test_command_killthreads.py b/tests/gdb-tests/tests/test_command_killthreads.py
new file mode 100644
--- /dev/null
+++ b/tests/gdb-tests/tests/test_command_killthreads.py
@@ -0,0 +1,80 @@
+import time
+
+import gdb
+
+import tests
+
+REFERENCE_BINARY_THREADS = tests.binaries.get("multiple_threads.out")
+
+
+def wait_until(predicate: callable, timeout: int = 5):
+    """
+    Waits until the predicate returns True or timeout is reached.
+    """
+    counter = 0
+    while True:
+        if predicate():
+            return True
+        time.sleep(0.1)
+        counter += 0.1
+        if counter > timeout:
+            assert False, "Timeout reached"
+
+
+def test_command_killthreads_kills_all_threads_except_current(start_binary):
+    start_binary(REFERENCE_BINARY_THREADS)
+
+    gdb.execute("break break_here")
+    gdb.execute("run")
+    wait_until(lambda: len(gdb.selected_inferior().threads()) == 3)
+
+    gdb.execute("killthreads --all")
+
+    # check if only one thread is left
+    wait_until(lambda: len(gdb.selected_inferior().threads()) == 1)
+
+    gdb.execute("continue")
+    exit_code = gdb.execute("print $_exitcode", to_string=True)
+    assert exit_code == "$1 = 0\n"  # if the second thread was killed, the exit code should be 0
+
+
+def test_command_killthreads_kills_specific_thread(start_binary):
+    start_binary(REFERENCE_BINARY_THREADS)
+
+    gdb.execute("break break_here")
+    gdb.execute("run")
+    initial_thread_count = len(gdb.selected_inferior().threads())
+    # check if thread with id 3 exists
+    wait_until(
+        lambda: len([thread for thread in gdb.selected_inferior().threads() if thread.num == 3])
+        == 1
+    )
+    gdb.execute("killthreads 3")
+    # check if the thread was killed, and no other thread was killed
+    wait_until(
+        lambda: len([thread for thread in gdb.selected_inferior().threads() if thread.num == 3])
+        == 0
+    )
+    assert len(gdb.selected_inferior().threads()) == initial_thread_count - 1
+
+    gdb.execute("kill")
+
+
+def test_command_killthreads_produces_error_when_unknown_thread_passed(start_binary):
+    start_binary(REFERENCE_BINARY_THREADS)
+
+    gdb.execute("break break_here")
+    gdb.execute("run")
+    initial_thread_count = len(gdb.selected_inferior().threads())
+    # check if thread with id 3 exists
+    assert len([thread for thread in gdb.selected_inferior().threads() if thread.num == 3]) == 1
+
+    out = gdb.execute("killthreads 999", to_string=True)
+    assert "Thread ID 999 does not exist" in out
+
+    gdb.execute("kill")
+
+
+def test_command_killthreads_before_binary_start():
+    result = gdb.execute("killthreads", to_string=True)
+    assert "The program is not being run" in result