security: close filesystem race findings

This commit is contained in:
Cristian Cezar Moisés 2026-08-31 20:27:14 -03:00
commit d8668e6d64
10 changed files with 698 additions and 133 deletions

View file

@ -121,6 +121,8 @@ jobs:
run: make CC=${{ matrix.cc }} V=1 WITH_SDK=0 WITH_PQBOX=0 check
- name: Extended upstream tests
run: make CC=${{ matrix.cc }} V=1 WITH_SDK=0 WITH_PQBOX=0 test-all
- name: In-tree SDK atomic key-save regression
run: make CC=${{ matrix.cc }} V=1 sdk-test
- name: Functional test of the built CLI
run: bash scripts/test-installed-zupt.sh "$PWD/zupt"

View file

@ -540,6 +540,7 @@ test-all: check
# Release-only gates need a committed Git checkout and packaging metadata.
# Keep them out of downstream %check, which intentionally has no dist rebuild.
release-check: test-all audit-licenses
$(Q)$(MAKE) sdk-test
$(Q)bash tests/test_static_analysis.sh
$(Q)bash tests/test_packaging_syntax.sh
$(Q)bash scripts/test-installed-zupt.sh ./$(TARGET)

View file

@ -556,20 +556,46 @@ void zuptsdk_keypair_destroy(zuptsdk_keypair_t *kp) {
static int zsdk_copy_file(const char *src, const char *dst, mode_t mode) {
FILE *fi = fopen(src, "rb");
if (!fi) return ZSDK_FAIL(ZUPTSDK_ERR_IO, "open %s", src);
FILE *fo = fopen(dst, "wb");
if (!fo) { fclose(fi); return ZSDK_FAIL(ZUPTSDK_ERR_IO, "create %s", dst); }
uint8_t buf[4096];
size_t n;
int rc = ZUPTSDK_OK;
while ((n = fread(buf, 1, sizeof(buf), fi)) > 0)
if (fwrite(buf, 1, n, fo) != n) { rc = ZSDK_FAIL(ZUPTSDK_ERR_IO, "write %s", dst); break; }
zuptsdk_secure_zero(buf, sizeof(buf));
fclose(fi); fclose(fo);
FILE *fo = NULL;
zupt_atomic_output_t *output = zupt_atomic_output_open(dst, &fo);
if (!output) {
int saved_errno = errno;
fclose(fi);
errno = saved_errno;
return ZSDK_FAIL(ZUPTSDK_ERR_IO, "create %s", dst);
}
#ifndef _WIN32
if (rc == ZUPTSDK_OK) chmod(dst, mode);
/* Apply permissions to the private temporary object, never to a
* re-resolved destination path. */
if (fchmod(fileno(fo), mode) != 0) {
int saved_errno = errno;
fclose(fi);
(void)zupt_atomic_output_finish(output, 0);
errno = saved_errno;
return ZSDK_FAIL(ZUPTSDK_ERR_IO, "set permissions on %s", dst);
}
#else
(void)mode;
#endif
uint8_t buf[4096];
size_t n;
int rc = ZUPTSDK_OK;
while ((n = fread(buf, 1, sizeof(buf), fi)) > 0) {
if (fwrite(buf, 1, n, fo) != n) {
rc = ZSDK_FAIL(ZUPTSDK_ERR_IO, "write %s", dst);
break;
}
}
if (rc == ZUPTSDK_OK && ferror(fi))
rc = ZSDK_FAIL(ZUPTSDK_ERR_IO, "read %s", src);
zuptsdk_secure_zero(buf, sizeof(buf));
if (fclose(fi) != 0 && rc == ZUPTSDK_OK)
rc = ZSDK_FAIL(ZUPTSDK_ERR_IO, "close %s", src);
if (zupt_atomic_output_finish(output, rc == ZUPTSDK_OK) != 0 &&
rc == ZUPTSDK_OK)
rc = ZSDK_FAIL(ZUPTSDK_ERR_IO, "publish %s", dst);
return rc;
}

View file

@ -12,6 +12,9 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifndef _WIN32
#include <sys/stat.h>
#endif
#include <unistd.h>
static int g_pass = 0, g_fail = 0;
@ -47,6 +50,79 @@ static const uint8_t TEST_DATA[] =
"Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod. "
"End of test data.\n";
#ifndef _WIN32
static int file_matches(const char *path, const void *expected,
size_t expected_size) {
struct stat info;
char observed[128];
if (expected_size > sizeof(observed) || stat(path, &info) != 0 ||
info.st_size < 0 || (uint64_t)info.st_size != (uint64_t)expected_size)
return 0;
FILE *stream = fopen(path, "rb");
if (!stream) return 0;
size_t got = fread(observed, 1, expected_size, stream);
int read_error = ferror(stream);
int close_rc = fclose(stream);
return got == expected_size && !read_error && close_rc == 0 &&
memcmp(observed, expected, expected_size) == 0;
}
static int private_key_save_avoids_link_targets(const zuptsdk_keypair_t *kp) {
static const char sentinel[] = "do not replace through a symlink\n";
char workspace[] = "/tmp/zupt-sdk-link-save.XXXXXX";
char target[192];
char symlink_path[192];
char hardlink_path[192];
FILE *stream;
struct stat target_st;
struct stat output_st;
int ok = 0;
if (!mkdtemp(workspace)) return 0;
snprintf(target, sizeof(target), "%s/target", workspace);
snprintf(symlink_path, sizeof(symlink_path), "%s/symlink-output",
workspace);
snprintf(hardlink_path, sizeof(hardlink_path), "%s/hardlink-output",
workspace);
stream = fopen(target, "wb");
if (!stream) goto cleanup;
size_t written = fwrite(sentinel, 1, sizeof(sentinel) - 1, stream);
int close_rc = fclose(stream);
if (written != sizeof(sentinel) - 1 || close_rc != 0)
goto cleanup;
if (symlink(target, symlink_path) != 0 ||
zuptsdk_keypair_save_private(kp, symlink_path) != ZUPTSDK_OK ||
!file_matches(target, sentinel, sizeof(sentinel) - 1) ||
stat(target, &target_st) != 0 || lstat(symlink_path, &output_st) != 0 ||
(target_st.st_dev == output_st.st_dev &&
target_st.st_ino == output_st.st_ino) ||
!S_ISREG(output_st.st_mode) || output_st.st_size <= 0 ||
(output_st.st_mode & 0777) != 0600)
goto cleanup;
if (link(target, hardlink_path) != 0 ||
zuptsdk_keypair_save_private(kp, hardlink_path) != ZUPTSDK_OK ||
!file_matches(target, sentinel, sizeof(sentinel) - 1) ||
stat(target, &target_st) != 0 || stat(hardlink_path, &output_st) != 0 ||
(target_st.st_dev == output_st.st_dev &&
target_st.st_ino == output_st.st_ino) ||
!S_ISREG(output_st.st_mode) || output_st.st_size <= 0 ||
(output_st.st_mode & 0777) != 0600)
goto cleanup;
ok = 1;
cleanup:
unlink(symlink_path);
unlink(hardlink_path);
unlink(target);
rmdir(workspace);
return ok;
}
#endif
static void test_version(void) {
TEST("version_string returns non-NULL");
const char *v = zuptsdk_version_string();
@ -250,6 +326,15 @@ cleanup:
static void test_keypair_pq(void) {
TEST("keypair_generate + compress_pq + extract_pq");
char saved_priv[160];
char saved_pub[160];
snprintf(saved_priv, sizeof(saved_priv), "/tmp/_zsdk_priv_%ld.key",
(long)getpid());
snprintf(saved_pub, sizeof(saved_pub), "/tmp/_zsdk_pub_%ld.key",
(long)getpid());
unlink(saved_priv);
unlink(saved_pub);
zuptsdk_ctx_t *ctx = NULL;
CHECK(zuptsdk_ctx_create(&ctx), "ctx");
@ -257,17 +342,35 @@ static void test_keypair_pq(void) {
int rc = zuptsdk_keypair_generate(ctx, &kp);
if (rc != ZUPTSDK_OK) { FAIL("keygen"); zuptsdk_ctx_destroy(ctx); return; }
#ifndef _WIN32
if (!private_key_save_avoids_link_targets(kp)) {
FAIL("private key save followed a symlink or hardlink target");
goto err;
}
#endif
/* Save and load to exercise that path too */
rc = zuptsdk_keypair_save_private(kp, "/tmp/_zsdk_priv.key");
rc = zuptsdk_keypair_save_private(kp, saved_priv);
if (rc != ZUPTSDK_OK) { FAIL("save priv"); goto err; }
rc = zuptsdk_keypair_save_public(kp, "/tmp/_zsdk_pub.key");
rc = zuptsdk_keypair_save_public(kp, saved_pub);
if (rc != ZUPTSDK_OK) { FAIL("save pub"); goto err; }
#ifndef _WIN32
struct stat private_st;
struct stat public_st;
if (stat(saved_priv, &private_st) != 0 ||
stat(saved_pub, &public_st) != 0 ||
(private_st.st_mode & 0777) != 0600 ||
(public_st.st_mode & 0777) != 0644) {
FAIL("saved key permissions do not match the requested modes");
goto err;
}
#endif
zuptsdk_pubkey_t *pub = NULL;
zuptsdk_privkey_t *priv = NULL;
rc = zuptsdk_pubkey_load("/tmp/_zsdk_pub.key", &pub);
rc = zuptsdk_pubkey_load(saved_pub, &pub);
if (rc != ZUPTSDK_OK) { FAIL("load pub"); goto err; }
rc = zuptsdk_privkey_load("/tmp/_zsdk_priv.key", &priv);
rc = zuptsdk_privkey_load(saved_priv, &priv);
if (rc != ZUPTSDK_OK) { FAIL("load priv"); zuptsdk_pubkey_destroy(pub); goto err; }
zuptsdk_options_t *opts = NULL;
@ -295,8 +398,8 @@ static void test_keypair_pq(void) {
zuptsdk_privkey_destroy(priv);
zuptsdk_options_destroy(opts);
unlink("/tmp/_zsdk_priv.key");
unlink("/tmp/_zsdk_pub.key");
unlink(saved_priv);
unlink(saved_pub);
if (!ok) { FAIL("byte mismatch or rc != OK"); zuptsdk_keypair_destroy(kp); zuptsdk_ctx_destroy(ctx); return; }
zuptsdk_keypair_destroy(kp);
@ -305,6 +408,8 @@ static void test_keypair_pq(void) {
return;
err:
unlink(saved_priv);
unlink(saved_pub);
zuptsdk_keypair_destroy(kp);
zuptsdk_ctx_destroy(ctx);
}

View file

@ -1220,23 +1220,35 @@ zupt_error_t zupt_disk_restore(const char *archive_path, const char *target_path
#else
int tgt_fd = -1;
int is_block_dev = 0;
struct stat target_st;
if (lstat(target_path, &target_st) == 0) {
if (S_ISLNK(target_st.st_mode)) {
fprintf(stderr, "Error: refusing a symbolic-link restore target.\n");
/* Resolve the target exactly once before making any type or identity
* decision. The open is non-truncating, O_NOFOLLOW rejects a final
* symlink, and fstat classifies the kernel object that was actually
* opened. Device restores retain this same descriptor through the final
* write, so a concurrent pathname exchange cannot redirect the restore. */
tgt_fd = open(target_path, O_WRONLY | O_NOFOLLOW | O_CLOEXEC |
O_NONBLOCK | O_SYNC);
if (tgt_fd >= 0) {
struct stat opened_st;
if (fstat(tgt_fd, &opened_st) != 0) {
int saved_errno = errno;
close(tgt_fd);
tgt_fd = -1;
errno = saved_errno;
} else if (S_ISREG(opened_st.st_mode)) {
int close_result = close(tgt_fd);
tgt_fd = -1;
if (close_result != 0) {
fclose(f);
return ZUPT_ERR_INVALID;
return ZUPT_ERR_IO;
}
if (S_ISREG(target_st.st_mode)) {
if (target_st.st_dev == archive_identity.device &&
target_st.st_ino == archive_identity.inode) {
if (opened_st.st_dev == archive_identity.device &&
opened_st.st_ino == archive_identity.inode) {
fprintf(stderr,
"Error: archive and restore target are the same file.\n");
fclose(f);
return ZUPT_ERR_INVALID;
}
if (target_st.st_nlink != 1) {
if (opened_st.st_nlink != 1) {
fprintf(stderr,
"Error: refusing a multiply-linked restore target.\n");
fclose(f);
@ -1244,21 +1256,8 @@ zupt_error_t zupt_disk_restore(const char *archive_path, const char *target_path
}
target_atomic =
zupt_atomic_output_open(target_path, &target_stream);
} else if (S_ISBLK(target_st.st_mode) ||
S_ISCHR(target_st.st_mode)) {
tgt_fd = open(target_path, O_WRONLY | O_NOFOLLOW | O_CLOEXEC |
O_NONBLOCK | O_SYNC);
if (tgt_fd >= 0) {
struct stat opened_st;
if (fstat(tgt_fd, &opened_st) != 0 ||
opened_st.st_dev != target_st.st_dev ||
opened_st.st_ino != target_st.st_ino ||
!(S_ISBLK(opened_st.st_mode) ||
S_ISCHR(opened_st.st_mode))) {
close(tgt_fd);
tgt_fd = -1;
errno = EAGAIN;
} else {
} else if (S_ISBLK(opened_st.st_mode) ||
S_ISCHR(opened_st.st_mode)) {
int flags = fcntl(tgt_fd, F_GETFL);
if (flags < 0 ||
fcntl(tgt_fd, F_SETFL, flags & ~O_NONBLOCK) != 0) {
@ -1294,9 +1293,9 @@ zupt_error_t zupt_disk_restore(const char *archive_path, const char *target_path
tgt_fd = -1;
#endif
}
}
}
} else {
close(tgt_fd);
tgt_fd = -1;
fprintf(stderr,
"Error: restore target is not a regular file or device.\n");
fclose(f);
@ -1304,8 +1303,12 @@ zupt_error_t zupt_disk_restore(const char *archive_path, const char *target_path
}
} else if (errno == ENOENT) {
target_atomic = zupt_atomic_output_open(target_path, &target_stream);
} else if (errno == ELOOP) {
fprintf(stderr, "Error: refusing a symbolic-link restore target.\n");
fclose(f);
return ZUPT_ERR_INVALID;
} else {
fprintf(stderr, "Error: Cannot inspect target '%s': %s\n",
fprintf(stderr, "Error: Cannot open target '%s': %s\n",
target_path, strerror(errno));
fclose(f);
return ZUPT_ERR_IO;

View file

@ -25,7 +25,9 @@
#ifdef _WIN32
#include <conio.h>
#include <windows.h>
#include <winternl.h>
#else
#include <fcntl.h>
#include <signal.h>
#include <termios.h>
#endif
@ -100,9 +102,11 @@ static int zupt_create_private_temp_directory(char *output, size_t capacity) {
}
return 0;
#else
static const char pattern[] = "/tmp/zupt-bench-XXXXXX";
if (sizeof(pattern) > capacity) return 0;
memcpy(output, pattern, sizeof(pattern));
char temp_root[ZUPT_MAX_PATH];
if (!realpath("/tmp", temp_root)) return 0;
int written = snprintf(output, capacity, "%s/zupt-bench-XXXXXX",
temp_root);
if (written < 0 || (size_t)written >= capacity) return 0;
if (!mkdtemp(output)) return 0;
if (chmod(output, 0700) != 0) {
rmdir(output);
@ -114,7 +118,97 @@ static int zupt_create_private_temp_directory(char *output, size_t capacity) {
}
#ifdef _WIN32
static int zupt_remove_tree_wide(const wchar_t *directory) {
static void zupt_win_set_cleanup_errno(NTSTATUS status) {
if (status == (NTSTATUS)0xC0000034L || /* STATUS_OBJECT_NAME_NOT_FOUND */
status == (NTSTATUS)0xC000003AL) { /* STATUS_OBJECT_PATH_NOT_FOUND */
errno = ENOENT;
} else {
errno = EACCES;
}
}
/* Open one entry relative to a pinned parent. Omitting FILE_SHARE_DELETE
* keeps the name bound to this handle until cleanup finishes; opening the
* reparse point itself prevents a junction or symlink from redirecting the
* recursive walk. */
static HANDLE zupt_win_open_cleanup_entry(HANDLE parent,
const wchar_t *name,
int directory_only,
int delete_access) {
size_t name_length = wcslen(name);
if (name_length == 0 ||
name_length > (size_t)USHRT_MAX / sizeof(wchar_t)) {
errno = ENAMETOOLONG;
return INVALID_HANDLE_VALUE;
}
UNICODE_STRING object_name;
object_name.Buffer = (PWSTR)name;
object_name.Length = (USHORT)(name_length * sizeof(wchar_t));
object_name.MaximumLength = object_name.Length + sizeof(wchar_t);
OBJECT_ATTRIBUTES attributes;
InitializeObjectAttributes(&attributes, &object_name,
OBJ_CASE_INSENSITIVE, parent, NULL);
IO_STATUS_BLOCK status_block;
HANDLE handle = INVALID_HANDLE_VALUE;
ACCESS_MASK access = FILE_LIST_DIRECTORY | FILE_TRAVERSE |
FILE_READ_ATTRIBUTES | SYNCHRONIZE;
if (delete_access) access |= DELETE;
ULONG share = FILE_SHARE_READ | FILE_SHARE_WRITE;
if (delete_access) share |= FILE_SHARE_DELETE;
ULONG options = FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT;
if (directory_only) options |= FILE_DIRECTORY_FILE;
NTSTATUS status = NtCreateFile(
&handle, access, &attributes, &status_block, NULL,
FILE_ATTRIBUTE_NORMAL, share, FILE_OPEN,
options, NULL, 0);
if (status < 0 || handle == INVALID_HANDLE_VALUE) {
zupt_win_set_cleanup_errno(status);
return INVALID_HANDLE_VALUE;
}
return handle;
}
/* Mark the exact object held by an identity-checked deletion handle. */
static int zupt_win_delete_cleanup_handle(HANDLE handle) {
FILE_DISPOSITION_INFO disposition;
disposition.DeleteFile = TRUE;
if (SetFileInformationByHandle(handle, FileDispositionInfo,
&disposition, sizeof(disposition)))
return 1;
errno = EACCES;
return 0;
}
/* Reopen an emptied child only after closing its no-delete-sharing traversal
* handle. Comparing the filesystem identity before marking the new handle
* for deletion makes a close/reopen name exchange fail safely. */
static int zupt_win_delete_cleanup_entry(
HANDLE parent, const wchar_t *name,
const BY_HANDLE_FILE_INFORMATION *expected) {
HANDLE handle = zupt_win_open_cleanup_entry(parent, name, 1, 1);
if (handle == INVALID_HANDLE_VALUE) return 0;
BY_HANDLE_FILE_INFORMATION current;
int same = GetFileInformationByHandle(handle, &current) &&
(current.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 &&
(current.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 &&
current.dwVolumeSerialNumber == expected->dwVolumeSerialNumber &&
current.nFileIndexHigh == expected->nFileIndexHigh &&
current.nFileIndexLow == expected->nFileIndexLow;
int deleted = same && zupt_win_delete_cleanup_handle(handle);
int closed = CloseHandle(handle) != 0;
if (!same) errno = EBUSY;
return deleted && closed;
}
static int zupt_win_plain_directory(HANDLE handle) {
BY_HANDLE_FILE_INFORMATION info;
return GetFileInformationByHandle(handle, &info) &&
(info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 &&
(info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0;
}
static int zupt_remove_tree_wide(HANDLE directory_handle,
const wchar_t *directory) {
size_t directory_length = wcslen(directory);
wchar_t *pattern = (wchar_t *)calloc(directory_length + 3u,
sizeof(*pattern));
@ -125,6 +219,8 @@ static int zupt_remove_tree_wide(const wchar_t *directory) {
WIN32_FIND_DATAW data;
HANDLE search = FindFirstFileW(pattern, &data);
DWORD search_error = search == INVALID_HANDLE_VALUE
? GetLastError() : ERROR_SUCCESS;
free(pattern);
int failed = 0;
if (search != INVALID_HANDLE_VALUE) {
@ -143,23 +239,217 @@ static int zupt_remove_tree_wide(const wchar_t *directory) {
child[directory_length] = L'\\';
memcpy(child + directory_length + 1u, data.cFileName,
(name_length + 1u) * sizeof(*child));
if ((data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0) {
if ((data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) {
if (!RemoveDirectoryW(child)) failed = 1;
} else if (zupt_remove_tree_wide(child) != 0) {
failed = 1;
if (DeleteFileW(child) || RemoveDirectoryW(child)) {
free(child);
continue;
}
} else {
SetFileAttributesW(child, FILE_ATTRIBUTE_NORMAL);
if (!DeleteFileW(child)) failed = 1;
DWORD delete_error = GetLastError();
if (delete_error == ERROR_FILE_NOT_FOUND ||
delete_error == ERROR_PATH_NOT_FOUND) {
free(child);
continue;
}
HANDLE child_handle = zupt_win_open_cleanup_entry(
directory_handle, data.cFileName, 1, 0);
if (child_handle == INVALID_HANDLE_VALUE) {
if (errno != ENOENT) failed = 1;
free(child);
continue;
}
int child_failed = 0;
BY_HANDLE_FILE_INFORMATION child_identity;
if (!GetFileInformationByHandle(child_handle, &child_identity) ||
(child_identity.dwFileAttributes &
FILE_ATTRIBUTE_DIRECTORY) == 0 ||
(child_identity.dwFileAttributes &
FILE_ATTRIBUTE_REPARSE_POINT) != 0 ||
zupt_remove_tree_wide(child_handle, child) != 0)
child_failed = 1;
if (!CloseHandle(child_handle)) child_failed = 1;
if (!child_failed && !zupt_win_delete_cleanup_entry(
directory_handle, data.cFileName, &child_identity))
child_failed = 1;
if (child_failed) failed = 1;
free(child);
} while (FindNextFileW(search, &data));
if (GetLastError() != ERROR_NO_MORE_FILES) failed = 1;
if (!FindClose(search)) failed = 1;
} else if (GetLastError() != ERROR_FILE_NOT_FOUND) {
} else if (search_error != ERROR_FILE_NOT_FOUND) {
failed = 1;
}
if (!RemoveDirectoryW(directory)) failed = 1;
return failed ? -1 : 0;
}
/* Resolve the absolute temporary path one component at a time and retain
* every ancestor handle. This makes the pathname used for enumeration
* stable even if another process tries to exchange an ancestor directory. */
static int zupt_win_open_cleanup_path(
const wchar_t *directory, wchar_t full[ZUPT_MAX_PATH + 256],
HANDLE **handles_out, size_t *handle_count_out) {
if (!_wfullpath(full, directory, ZUPT_MAX_PATH + 256)) {
errno = EINVAL;
return 0;
}
for (wchar_t *p = full; *p; p++) if (*p == L'/') *p = L'\\';
if ((full[0] == L'\\' && full[1] == L'\\') ||
!(full[0] && full[1] == L':' && full[2] == L'\\')) {
errno = EINVAL;
return 0;
}
size_t capacity = wcslen(full) + 1u;
HANDLE *handles = (HANDLE *)calloc(capacity, sizeof(*handles));
if (!handles) return 0;
wchar_t drive_root[4] = {full[0], L':', L'\\', L'\0'};
HANDLE current = CreateFileW(
drive_root,
FILE_LIST_DIRECTORY | FILE_TRAVERSE | FILE_READ_ATTRIBUTES |
SYNCHRONIZE,
FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL);
if (current == INVALID_HANDLE_VALUE ||
!zupt_win_plain_directory(current)) {
DWORD open_error = current == INVALID_HANDLE_VALUE
? GetLastError() : ERROR_ACCESS_DENIED;
if (current != INVALID_HANDLE_VALUE) CloseHandle(current);
free(handles);
errno = open_error == ERROR_FILE_NOT_FOUND ||
open_error == ERROR_PATH_NOT_FOUND
? ENOENT : EACCES;
return 0;
}
size_t count = 0;
handles[count++] = current;
wchar_t *scan = full + 3;
while (*scan) {
wchar_t *separator = wcschr(scan, L'\\');
if (separator) *separator = L'\0';
HANDLE next = zupt_win_open_cleanup_entry(
current, scan, 1, 0);
if (separator) *separator = L'\\';
if (next == INVALID_HANDLE_VALUE ||
!zupt_win_plain_directory(next)) {
if (next != INVALID_HANDLE_VALUE) CloseHandle(next);
while (count > 0) CloseHandle(handles[--count]);
free(handles);
if (next != INVALID_HANDLE_VALUE) errno = EACCES;
return 0;
}
handles[count++] = next;
current = next;
if (!separator) break;
scan = separator + 1;
}
*handles_out = handles;
*handle_count_out = count;
return 1;
}
#endif
#ifndef _WIN32
/* Resolve every component without following symlinks and return both the
* pinned target and its pinned parent. The caller can therefore remove the
* final directory with unlinkat() instead of resolving its pathname again. */
static int zupt_open_temp_tree(const char *path, int *parent_out,
int *directory_out, char *leaf,
size_t leaf_capacity) {
if (!path || !*path || !parent_out || !directory_out || !leaf ||
leaf_capacity == 0) {
errno = EINVAL;
return 0;
}
int current = open(path[0] == '/' ? "/" : ".",
O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (current < 0) return 0;
const char *cursor = path;
while (*cursor == '/') cursor++;
while (*cursor) {
const char *start = cursor;
while (*cursor && *cursor != '/') cursor++;
size_t component_length = (size_t)(cursor - start);
while (*cursor == '/') cursor++;
int final_component = *cursor == '\0';
if ((component_length == 1u && start[0] == '.') ||
component_length == 0u) {
if (final_component) {
close(current);
errno = EINVAL;
return 0;
}
continue;
}
if (component_length == 2u && start[0] == '.' && start[1] == '.') {
close(current);
errno = EINVAL;
return 0;
}
if (component_length >= leaf_capacity) {
close(current);
errno = ENAMETOOLONG;
return 0;
}
memcpy(leaf, start, component_length);
leaf[component_length] = '\0';
int next = openat(current, leaf,
O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
if (next < 0) {
int saved_errno = errno;
close(current);
errno = saved_errno;
return 0;
}
if (final_component) {
*parent_out = current;
*directory_out = next;
return 1;
}
close(current);
current = next;
}
close(current);
errno = EINVAL;
return 0;
}
/* Delete leaves before attempting to open them as directories. unlinkat()
* never follows a symlink; a directory is recursively visited only through
* an O_NOFOLLOW descriptor returned by openat(). */
static int zupt_remove_temp_tree_fd(int directory_fd) {
DIR *stream = fdopendir(directory_fd);
if (!stream) {
close(directory_fd);
return -1;
}
int failed = 0;
int parent_fd = dirfd(stream);
for (;;) {
errno = 0;
struct dirent *entry = readdir(stream);
if (!entry) {
if (errno != 0) failed = 1;
break;
}
if (strcmp(entry->d_name, ".") == 0 ||
strcmp(entry->d_name, "..") == 0)
continue;
if (unlinkat(parent_fd, entry->d_name, 0) == 0 || errno == ENOENT)
continue;
int child_fd = openat(parent_fd, entry->d_name,
O_RDONLY | O_DIRECTORY | O_NOFOLLOW |
O_CLOEXEC);
if (child_fd < 0) {
if (errno != ENOENT) failed = 1;
continue;
}
if (zupt_remove_temp_tree_fd(child_fd) != 0) failed = 1;
if (unlinkat(parent_fd, entry->d_name, AT_REMOVEDIR) != 0 &&
errno != ENOENT)
failed = 1;
}
if (closedir(stream) != 0) failed = 1;
return failed ? -1 : 0;
}
#endif
@ -169,41 +459,43 @@ static int zupt_remove_temp_tree(const char *directory) {
#ifdef _WIN32
wchar_t *wide = zupt_win_utf8_to_wide_alloc(directory);
if (!wide) return -1;
int result = zupt_remove_tree_wide(wide);
wchar_t full[ZUPT_MAX_PATH + 256];
HANDLE *handles = NULL;
size_t handle_count = 0;
if (!zupt_win_open_cleanup_path(wide, full, &handles, &handle_count)) {
int result = errno == ENOENT ? 0 : -1;
free(wide);
return result;
}
HANDLE root_handle = handles[handle_count - 1u];
int result = zupt_remove_tree_wide(root_handle, full);
BY_HANDLE_FILE_INFORMATION root_identity;
if (result == 0 && !GetFileInformationByHandle(root_handle,
&root_identity))
result = -1;
const wchar_t *root_name = wcsrchr(full, L'\\');
if (!root_name || root_name[1] == L'\0') result = -1;
else root_name++;
if (!CloseHandle(handles[--handle_count])) result = -1;
if (result == 0 && !zupt_win_delete_cleanup_entry(
handles[handle_count - 1u], root_name, &root_identity))
result = -1;
while (handle_count > 0)
if (!CloseHandle(handles[--handle_count])) result = -1;
free(handles);
free(wide);
return result;
#else
DIR *stream = opendir(directory);
if (!stream) return errno == ENOENT ? 0 : -1;
int failed = 0;
struct dirent *entry;
while ((entry = readdir(stream)) != NULL) {
if (strcmp(entry->d_name, ".") == 0 ||
strcmp(entry->d_name, "..") == 0)
continue;
size_t needed = strlen(directory) + strlen(entry->d_name) + 2u;
char *child = (char *)malloc(needed);
if (!child) {
int parent_fd = -1;
int directory_fd = -1;
char leaf[ZUPT_MAX_PATH];
if (!zupt_open_temp_tree(directory, &parent_fd, &directory_fd,
leaf, sizeof(leaf)))
return errno == ENOENT ? 0 : -1;
int failed = zupt_remove_temp_tree_fd(directory_fd) != 0;
if (unlinkat(parent_fd, leaf, AT_REMOVEDIR) != 0 && errno != ENOENT)
failed = 1;
continue;
}
if (!zupt_join_temp_path(child, needed, directory, entry->d_name)) {
free(child);
failed = 1;
continue;
}
struct stat info;
if (lstat(child, &info) != 0) {
failed = 1;
} else if (S_ISDIR(info.st_mode)) {
if (zupt_remove_temp_tree(child) != 0) failed = 1;
} else if (unlink(child) != 0) {
failed = 1;
}
free(child);
}
if (closedir(stream) != 0) failed = 1;
if (rmdir(directory) != 0) failed = 1;
if (close(parent_fd) != 0) failed = 1;
return failed ? -1 : 0;
#endif
}
@ -429,7 +721,11 @@ static int prompt_password(const char *prompt, char *buf, size_t cap) {
if (!buf || cap < 2) return 0;
buf[0] = '\0';
#ifdef _WIN32
if (!_isatty(_fileno(stdin))) {
HANDLE input_handle = GetStdHandle(STD_INPUT_HANDLE);
DWORD input_mode = 0;
if (input_handle == NULL || input_handle == INVALID_HANDLE_VALUE ||
GetFileType(input_handle) != FILE_TYPE_CHAR ||
!GetConsoleMode(input_handle, &input_mode)) {
fprintf(stderr, "Error: password prompt requires a terminal.\n");
return 0;
}
@ -445,6 +741,11 @@ static int prompt_password(const char *prompt, char *buf, size_t cap) {
int too_long = 0;
for (;;) {
int c = _getch();
if (c == EOF) {
zupt_secure_wipe(buf, cap);
fprintf(stderr, "\nError: cannot read password prompt.\n");
return 0;
}
if (c == '\r' || c == '\n') break;
if (c == 0 || c == 0xe0) {
(void)_getch();

View file

@ -3,6 +3,11 @@
set -Eeuo pipefail
bin=${1:-./zupt}
repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P)
case $bin in
/*) ;;
*) bin="$(pwd -P)/${bin#./}" ;;
esac
tmp=$(mktemp -d "${TMPDIR:-/tmp}/zupt-bench-safety.XXXXXXXX")
trap 'rm -rf -- "$tmp"' EXIT HUP INT TERM
@ -11,9 +16,36 @@ fail() {
exit 1
}
# CodeQL #7 reported the old lstat(child) -> recursive pathname operation as
# cpp/toctou-race-condition. Keep the platform-specific cleanup primitives in
# the source gate as well as exercising the runtime symlink boundary below.
cleanup_source=$repo_root/src/zupt_main.c
grep -Fq 'static int zupt_remove_temp_tree_fd(int directory_fd)' \
"$cleanup_source" || fail 'POSIX descriptor-relative cleanup is missing'
grep -Fq 'unlinkat(parent_fd, entry->d_name, 0)' "$cleanup_source" ||
fail 'POSIX leaf cleanup is not unlinkat-relative'
grep -Fq 'directory_handle, data.cFileName, 1, 0)' "$cleanup_source" ||
fail 'Windows recursive cleanup is not handle-relative'
grep -Fq 'FILE_OPEN_REPARSE_POINT' "$cleanup_source" ||
fail 'Windows cleanup no longer opens reparse points without following'
grep -Fq 'zupt_win_delete_cleanup_entry(' "$cleanup_source" ||
fail 'Windows cleanup lacks identity-checked handle deletion'
grep -Fq 'current.nFileIndexLow == expected->nFileIndexLow' "$cleanup_source" ||
fail 'Windows cleanup no longer rejects a close/reopen name exchange'
if grep -Fq 'RemoveDirectoryW(full)' "$cleanup_source"; then
fail 'Windows root cleanup restored post-handle pathname deletion'
fi
if grep -Fq 'lstat(child' "$cleanup_source" ||
grep -Fq 'zupt_remove_temp_tree(child' "$cleanup_source"; then
fail 'temporary cleanup restored a check-then-use pathname traversal'
fi
case $(uname -s 2>/dev/null || printf unknown) in
MINGW*|MSYS*|CYGWIN*)
printf 'SKIP: historical POSIX /tmp symlink benchmark test is not native on Windows\n'
"$bin" bench --compare >/dev/null 2>&1 ||
fail 'native Windows handle-relative benchmark cleanup failed'
printf 'SKIP: adversarial POSIX symlink injection is not native on Windows\n'
printf 'private Windows handle-relative benchmark workspace: PASS\n'
exit 0
;;
esac
@ -46,4 +78,50 @@ if [[ -d $old_directory ]]; then
mv "$old_directory" "$tmp/historical-remnant"
fi
printf 'private benchmark workspace: PASS\n'
# Inject a directory symlink into the private workspace while a real benchmark
# is active. Cleanup must remove the link itself and never visit its target.
mkdir "$tmp/symlink-target"
printf 'cleanup sentinel must survive\n' > "$tmp/symlink-target/sentinel"
cp "$tmp/symlink-target/sentinel" "$tmp/symlink-target.expected"
dd if=/dev/urandom of="$tmp/injection-input" bs=65536 count=128 2>/dev/null
physical_tmp=$(CDPATH='' cd -P -- /tmp && pwd -P)
: > "$tmp/preexisting-workspaces"
for candidate in "$physical_tmp"/zupt-bench-*; do
if [[ -d $candidate && ! -L $candidate ]]; then
printf '%s\n' "$candidate" >> "$tmp/preexisting-workspaces"
fi
done
(cd "$tmp" && "$bin" bench injection-input >/dev/null 2>&1) &
bench_pid=$!
injected=0
injected_workspace=
attempt=0
while (( attempt < 1000 )); do
for candidate in "$physical_tmp"/zupt-bench-*; do
[[ -d $candidate && ! -L $candidate ]] || continue
if grep -Fqx -- "$candidate" "$tmp/preexisting-workspaces"; then
continue
fi
if ln -s "$tmp/symlink-target" "$candidate/attacker-link" \
2>/dev/null; then
injected=1
injected_workspace=$candidate
break
fi
done
(( injected == 1 )) && break
kill -0 "$bench_pid" 2>/dev/null || break
sleep 0.01
attempt=$((attempt + 1))
done
wait "$bench_pid" || fail 'benchmark with injected symlink failed'
(( injected == 1 )) || fail 'could not observe the private benchmark workspace'
if [[ -e $injected_workspace || -L $injected_workspace ]]; then
fail 'injected workspace was not the benchmark tree that was removed'
fi
cmp "$tmp/symlink-target.expected" "$tmp/symlink-target/sentinel" ||
fail 'temporary cleanup followed an injected directory symlink'
printf 'private descriptor/handle-relative benchmark workspace: PASS\n'

View file

@ -56,9 +56,30 @@ if "$binary" test --pass-fd not-a-number archive.zupt >/dev/null 2>&1; then
exit 1
fi
if "$binary" test --password-prompt archive.zupt </dev/null >/dev/null 2>&1; then
prompt_log=$test_root/non-interactive-prompt.log
if command -v timeout >/dev/null 2>&1; then
set +e
timeout 10 "$binary" test --password-prompt archive.zupt \
</dev/null >"$prompt_log" 2>&1
prompt_status=$?
set -e
else
set +e
"$binary" test --password-prompt archive.zupt \
</dev/null >"$prompt_log" 2>&1
prompt_status=$?
set -e
fi
if ((prompt_status == 124)); then
printf '%s\n' 'FAIL: non-interactive password prompt timed out' >&2
exit 1
elif ((prompt_status == 0)); then
printf '%s\n' 'FAIL: non-interactive password prompt unexpectedly succeeded' >&2
exit 1
elif ! grep -Fq 'password prompt requires a terminal.' "$prompt_log"; then
printf 'FAIL: non-interactive password prompt returned status %d without a terminal rejection\n' \
"$prompt_status" >&2
exit 1
fi
case $(uname -s) in

View file

@ -154,7 +154,7 @@ case "$(uname -s)" in
tree=$(fresh_tree raw-c1-path)
control_name=$'raw-\200.txt'
printf '\177ELF\002\001\001\000compiled' >"$tree/$control_name"
if { printf '\177ELF\002\001\001\000compiled' >"$tree/$control_name"; } 2>/dev/null; then
if "$SCANNER" --tree "$tree" >"$TEST_TMP/output" 2>&1; then
printf 'not ok - raw C1 path was not rejected\n'
exit 1
@ -165,6 +165,9 @@ case "$(uname -s)" in
else
pass 'scanner escapes invalid raw C1 bytes in reported paths'
fi
else
skip 'raw C1 filenames are forbidden by this filesystem'
fi
tree=$(fresh_tree utf8-c1-path)
control_name=$'utf8-\302\233.txt'

View file

@ -187,6 +187,31 @@ else
F "ECHO bit-clear missing the explicit (tcflag_t) cast"
fi
# A restore to a device is irreversible. Classify the already-open descriptor
# rather than checking target_path and resolving that mutable name again.
if grep -Fq 'lstat(target_path' src/zupt_disk.c; then
F "disk restore has a path-check/open TOCTOU pattern"
elif grep -Fq 'tgt_fd = open(target_path' src/zupt_disk.c &&
grep -Fq 'fstat(tgt_fd, &opened_st)' src/zupt_disk.c; then
P "disk restore classifies the opened target descriptor"
else
F "disk restore descriptor-first target guard is missing"
fi
# CodeQL #5 reported chmod(dst, mode) after reopening/resolving the SDK save
# path. Key copies must use the core's handle/descriptor-relative atomic
# publisher and apply POSIX permissions to its already-open temporary stream.
if grep -Fq 'chmod(dst, mode)' sdk/src/zuptsdk.c; then
F "SDK key save has a path-based chmod TOCTOU pattern"
elif grep -Fq 'zupt_atomic_output_open(dst, &fo)' sdk/src/zuptsdk.c &&
grep -Fq 'fchmod(fileno(fo), mode)' sdk/src/zuptsdk.c &&
grep -Fq 'zupt_atomic_output_finish(output, rc == ZUPTSDK_OK)' \
sdk/src/zuptsdk.c; then
P "SDK key save uses descriptor-relative atomic publication"
else
F "SDK key save atomic publication guard is missing"
fi
echo ""
echo " ───────────────────────────────────────"
echo " Static analysis: $PASS passed, $FAIL failed"