Skip to content

Commit 23414c6

Browse files
dkorunicGopher Bot
authored andcommitted
BUG/MEDIUM: block_secrets stability regression
last change expanded the LD_PRELOAD interposer from the open() family to ~50 libc calls, including the stat/lstat/fstatat/statx/access/faccessat/ readlink/readlinkat/opendir family that HAProxy exercises constantly on its hot path, including inside process_chk while holding the per-SERVER lock. Each intercepted call runs is_blocked(), whose slow path executes a blocking, non-async-signal-safe realpath(); doing that in the event loop stalls threads on the SERVER lock and trips the "stopped processing traffic" watchdog, which escalates to recovery/crash (issue #818). Narrow interception back to the calls that actually read or mutate secrets (open/openat/fopen/freopen/creat and the write hooks) and drop the metadata- only hooks, so HAProxy's stat/access/readlink bind straight to libc and never detour through realpath(). Also fix a related musl bug: the LFS64 aliases (open64/stat64/fstatat64/ truncate64/renameat2) are not exported by musl, so dlsym(RTLD_NEXT, ...) returned NULL and the exported *64 hooks returned ENOSYS while HOOK_GUARD re-dlsym()'d on every call. Alias the *64 pointers to their base counterparts (ABI-identical on 64-bit) and special-case renameat2(flags==0) -> renameat. Add behavioural tests (block_secrets_test.{c,sh}) covering the security invariant and both regressions, runnable under the musl/alpine build image.
1 parent 52762dd commit 23414c6

5 files changed

Lines changed: 179 additions & 144 deletions

File tree

.aspell.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,17 @@ allowed:
123123
- GVK
124124
- Impl
125125
- failfast
126+
- PEM
127+
- redact
128+
- httpresponses
129+
- chk
130+
- faccessat
131+
- fstatat
132+
- interposer
133+
- lstat
134+
- opendir
135+
- readlink
136+
- readlinkat
137+
- realpath
138+
- runnable
139+
- statx

hug/protection/block_secrets.c

Lines changed: 38 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,14 @@
1212
/* See the License for the specific language governing permissions and */
1313
/* limitations under the License. */
1414

15+
/* LD_PRELOAD shim that denies HAProxy access to the Kubernetes service-account
16+
token dir (default /var/run/secrets/kubernetes.io). Interposes the libc
17+
calls that read or mutate a path and returns EACCES for anything resolving
18+
inside it. Metadata-only calls (stat/access/readlink) are deliberately NOT
19+
hooked so they stay off HAProxy's hot path (issue #818). */
20+
1521
#define _XOPEN_SOURCE 700
1622

17-
#include <dirent.h>
1823
#include <dlfcn.h>
1924
#include <errno.h>
2025
#include <fcntl.h>
@@ -34,7 +39,6 @@
3439
/* Forward-declared to avoid _GNU_SOURCE, which on musl macro-aliases the
3540
*64 stat names and would collide with our hook symbols. */
3641
struct file_handle;
37-
struct statx;
3842

3943
#ifndef PATH_MAX
4044
#define PATH_MAX 4096
@@ -54,7 +58,7 @@ static char text_blocked[PATH_MAX] = {0};
5458
static size_t text_blocked_len = 0;
5559
static int text_canonical_same = 1;
5660

57-
/* Recursion guard: musl's realpath() uses the public readlink symbol
61+
/* Recursion guard: realpath() resolves paths via the public open() symbol,
5862
which our hook would otherwise re-enter. */
5963
static __thread int in_is_blocked = 0;
6064

@@ -218,21 +222,6 @@ static int (*real_creat)(const char *, mode_t) = NULL;
218222
static int (*real_creat64)(const char *, mode_t) = NULL;
219223
static int (*real_openat)(int, const char *, int, ...) = NULL;
220224
static int (*real_openat64)(int, const char *, int, ...) = NULL;
221-
static int (*real_access)(const char *, int) = NULL;
222-
static int (*real_faccessat)(int, const char *, int, int) = NULL;
223-
static ssize_t (*real_readlink)(const char *, char *, size_t) = NULL;
224-
static ssize_t (*real_readlinkat)(int, const char *, char *, size_t) = NULL;
225-
static DIR *(*real_opendir)(const char *) = NULL;
226-
static int (*real_stat)(const char *, struct stat *) = NULL;
227-
static int (*real_lstat)(const char *, struct stat *) = NULL;
228-
static int (*real_fstatat)(int, const char *, struct stat *, int) = NULL;
229-
/* void * avoids _LARGEFILE64_SOURCE, which on musl macro-aliases stat64
230-
to stat and would break our hook symbol. */
231-
static int (*real_stat64)(const char *, void *) = NULL;
232-
static int (*real_lstat64)(const char *, void *) = NULL;
233-
static int (*real_fstatat64)(int, const char *, void *, int) = NULL;
234-
static int (*real_statx)(int, const char *, int, unsigned int,
235-
struct statx *) = NULL;
236225
static int (*real_name_to_handle_at)(int, const char *, struct file_handle *,
237226
int *, int) = NULL;
238227
static int (*real_mkdir)(const char *, mode_t) = NULL;
@@ -276,18 +265,6 @@ __attribute__((constructor(101), cold)) static void block_secrets_init(void) {
276265
real_creat64 = dlsym(RTLD_NEXT, "creat64");
277266
real_openat = dlsym(RTLD_NEXT, "openat");
278267
real_openat64 = dlsym(RTLD_NEXT, "openat64");
279-
real_access = dlsym(RTLD_NEXT, "access");
280-
real_faccessat = dlsym(RTLD_NEXT, "faccessat");
281-
real_readlink = dlsym(RTLD_NEXT, "readlink");
282-
real_readlinkat = dlsym(RTLD_NEXT, "readlinkat");
283-
real_opendir = dlsym(RTLD_NEXT, "opendir");
284-
real_stat = dlsym(RTLD_NEXT, "stat");
285-
real_lstat = dlsym(RTLD_NEXT, "lstat");
286-
real_fstatat = dlsym(RTLD_NEXT, "fstatat");
287-
real_stat64 = dlsym(RTLD_NEXT, "stat64");
288-
real_lstat64 = dlsym(RTLD_NEXT, "lstat64");
289-
real_fstatat64 = dlsym(RTLD_NEXT, "fstatat64");
290-
real_statx = dlsym(RTLD_NEXT, "statx");
291268
real_name_to_handle_at = dlsym(RTLD_NEXT, "name_to_handle_at");
292269
real_mkdir = dlsym(RTLD_NEXT, "mkdir");
293270
real_mkdirat = dlsym(RTLD_NEXT, "mkdirat");
@@ -314,6 +291,28 @@ __attribute__((constructor(101), cold)) static void block_secrets_init(void) {
314291
real_symlink = dlsym(RTLD_NEXT, "symlink");
315292
real_symlinkat = dlsym(RTLD_NEXT, "symlinkat");
316293

294+
/* musl exports no distinct LFS64 symbols, so the dlsym()s above leave these
295+
NULL. off_t is 64-bit on every supported ABI, so *64 == base: alias them.
296+
Otherwise the exported *64 hooks would return ENOSYS (issue #818). */
297+
if (real_open64 == NULL) {
298+
real_open64 = real_open;
299+
}
300+
if (real_fopen64 == NULL) {
301+
real_fopen64 = real_fopen;
302+
}
303+
if (real_freopen64 == NULL) {
304+
real_freopen64 = real_freopen;
305+
}
306+
if (real_creat64 == NULL) {
307+
real_creat64 = real_creat;
308+
}
309+
if (real_openat64 == NULL) {
310+
real_openat64 = real_openat;
311+
}
312+
if (real_truncate64 == NULL) {
313+
real_truncate64 = (int (*)(const char *, int64_t))real_truncate;
314+
}
315+
317316
const char *src = getenv(BLOCKED_PATH_ENV);
318317
if (src == NULL) {
319318
src = BLOCKED_PATH_DEFAULT;
@@ -477,119 +476,6 @@ HOOK_VISIBLE int openat64(int dirfd, const char *pathname, int flags, ...) {
477476
return real_openat64(dirfd, pathname, flags, mode);
478477
}
479478

480-
HOOK_VISIBLE int access(const char *pathname, int mode) {
481-
if (path_blocked(pathname)) {
482-
errno = EACCES;
483-
return -1;
484-
}
485-
HOOK_GUARD(real_access, -1);
486-
return real_access(pathname, mode);
487-
}
488-
489-
HOOK_VISIBLE int faccessat(int dirfd, const char *pathname, int mode,
490-
int flags) {
491-
if (path_blocked(pathname)) {
492-
errno = EACCES;
493-
return -1;
494-
}
495-
HOOK_GUARD(real_faccessat, -1);
496-
return real_faccessat(dirfd, pathname, mode, flags);
497-
}
498-
499-
HOOK_VISIBLE ssize_t readlink(const char *pathname, char *buf, size_t bufsiz) {
500-
if (path_blocked(pathname)) {
501-
errno = EACCES;
502-
return -1;
503-
}
504-
HOOK_GUARD(real_readlink, -1);
505-
return real_readlink(pathname, buf, bufsiz);
506-
}
507-
508-
HOOK_VISIBLE ssize_t readlinkat(int dirfd, const char *pathname, char *buf,
509-
size_t bufsiz) {
510-
if (path_blocked(pathname)) {
511-
errno = EACCES;
512-
return -1;
513-
}
514-
HOOK_GUARD(real_readlinkat, -1);
515-
return real_readlinkat(dirfd, pathname, buf, bufsiz);
516-
}
517-
518-
HOOK_VISIBLE DIR *opendir(const char *name) {
519-
if (path_blocked(name)) {
520-
errno = EACCES;
521-
return NULL;
522-
}
523-
HOOK_GUARD(real_opendir, NULL);
524-
return real_opendir(name);
525-
}
526-
527-
HOOK_VISIBLE int stat(const char *pathname, struct stat *buf) {
528-
if (path_blocked(pathname)) {
529-
errno = EACCES;
530-
return -1;
531-
}
532-
HOOK_GUARD(real_stat, -1);
533-
return real_stat(pathname, buf);
534-
}
535-
536-
HOOK_VISIBLE int lstat(const char *pathname, struct stat *buf) {
537-
if (path_blocked(pathname)) {
538-
errno = EACCES;
539-
return -1;
540-
}
541-
HOOK_GUARD(real_lstat, -1);
542-
return real_lstat(pathname, buf);
543-
}
544-
545-
HOOK_VISIBLE int fstatat(int dirfd, const char *pathname, struct stat *buf,
546-
int flags) {
547-
if (path_blocked(pathname)) {
548-
errno = EACCES;
549-
return -1;
550-
}
551-
HOOK_GUARD(real_fstatat, -1);
552-
return real_fstatat(dirfd, pathname, buf, flags);
553-
}
554-
555-
HOOK_VISIBLE int stat64(const char *pathname, void *buf) {
556-
if (path_blocked(pathname)) {
557-
errno = EACCES;
558-
return -1;
559-
}
560-
HOOK_GUARD(real_stat64, -1);
561-
return real_stat64(pathname, buf);
562-
}
563-
564-
HOOK_VISIBLE int lstat64(const char *pathname, void *buf) {
565-
if (path_blocked(pathname)) {
566-
errno = EACCES;
567-
return -1;
568-
}
569-
HOOK_GUARD(real_lstat64, -1);
570-
return real_lstat64(pathname, buf);
571-
}
572-
573-
HOOK_VISIBLE int fstatat64(int dirfd, const char *pathname, void *buf,
574-
int flags) {
575-
if (path_blocked(pathname)) {
576-
errno = EACCES;
577-
return -1;
578-
}
579-
HOOK_GUARD(real_fstatat64, -1);
580-
return real_fstatat64(dirfd, pathname, buf, flags);
581-
}
582-
583-
HOOK_VISIBLE int statx(int dirfd, const char *pathname, int flags,
584-
unsigned int mask, struct statx *buf) {
585-
if (path_blocked(pathname)) {
586-
errno = EACCES;
587-
return -1;
588-
}
589-
HOOK_GUARD(real_statx, -1);
590-
return real_statx(dirfd, pathname, flags, mask, buf);
591-
}
592-
593479
HOOK_VISIBLE int name_to_handle_at(int dirfd, const char *pathname,
594480
struct file_handle *handle, int *mount_id,
595481
int flags) {
@@ -783,7 +669,15 @@ HOOK_VISIBLE int renameat2(int olddirfd, const char *oldpath, int newdirfd,
783669
errno = EACCES;
784670
return -1;
785671
}
786-
HOOK_GUARD(real_renameat2, -1);
672+
/* No renameat2 on musl: flags==0 is plain renameat(); a non-zero flag is
673+
genuinely unsupported, so ENOSYS is then the correct answer. */
674+
if (real_renameat2 == NULL) {
675+
if (flags == 0 && real_renameat != NULL) {
676+
return real_renameat(olddirfd, oldpath, newdirfd, newpath);
677+
}
678+
errno = ENOSYS;
679+
return -1;
680+
}
787681
return real_renameat2(olddirfd, oldpath, newdirfd, newpath, flags);
788682
}
789683

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/* Copyright 2025 HAProxy Technologies LLC */
2+
/* */
3+
/* Licensed under the Apache License, Version 2.0 (the "License"); */
4+
/* you may not use this file except in compliance with the License. */
5+
/* You may obtain a copy of the License at */
6+
/* */
7+
/* http://www.apache.org/licenses/LICENSE-2.0 */
8+
/* */
9+
/* Unless required by applicable law or agreed to in writing, software */
10+
/* distributed under the License is distributed on an "AS IS" BASIS, */
11+
/* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. */
12+
/* See the License for the specific language governing permissions and */
13+
/* limitations under the License. */
14+
15+
/* Behavioural tests for libblock_secrets.so, run preloaded by
16+
block_secrets_test.sh. Regression coverage for issue #818:
17+
- content reads (open/fopen) into the protected dir stay blocked;
18+
- metadata calls (stat/access) are no longer hooked;
19+
- LFS64 aliases (open64) must not return ENOSYS on musl. */
20+
21+
#define _GNU_SOURCE
22+
#include <dlfcn.h>
23+
#include <errno.h>
24+
#include <fcntl.h>
25+
#include <stdio.h>
26+
#include <stdlib.h>
27+
#include <string.h>
28+
#include <sys/stat.h>
29+
#include <unistd.h>
30+
31+
static int failures = 0;
32+
33+
static void check(int cond, const char *what) {
34+
printf(" [%s] %s\n", cond ? "PASS" : "FAIL", what);
35+
if (!cond) {
36+
failures++;
37+
}
38+
}
39+
40+
int main(void) {
41+
const char *blocked_file = getenv("TEST_BLOCKED_FILE"); /* inside protected dir */
42+
const char *normal_file = getenv("TEST_NORMAL_FILE"); /* outside protected dir */
43+
struct stat st;
44+
int fd;
45+
FILE *fp;
46+
47+
printf("== security: content reads into the protected dir stay blocked ==\n");
48+
errno = 0;
49+
fd = open(blocked_file, O_RDONLY);
50+
check(fd == -1 && errno == EACCES, "open(blocked) is denied with EACCES");
51+
if (fd != -1) {
52+
close(fd);
53+
}
54+
errno = 0;
55+
fp = fopen(blocked_file, "r");
56+
check(fp == NULL && errno == EACCES, "fopen(blocked) is denied with EACCES");
57+
if (fp) {
58+
fclose(fp);
59+
}
60+
61+
printf("== regression #818: metadata-only calls are no longer intercepted ==\n");
62+
errno = 0;
63+
check(stat(blocked_file, &st) == 0, "stat(blocked) succeeds (not hooked)");
64+
errno = 0;
65+
check(access(blocked_file, F_OK) == 0, "access(blocked) succeeds (not hooked)");
66+
67+
printf("== regression #818: LFS64 aliases must not return ENOSYS ==\n");
68+
int (*p_open64)(const char *, int, ...) = (void *)dlsym(RTLD_DEFAULT, "open64");
69+
if (p_open64) {
70+
errno = 0;
71+
fd = p_open64(normal_file, O_RDONLY);
72+
check(fd >= 0, "open64(normal) succeeds (no ENOSYS)");
73+
if (fd >= 0) {
74+
close(fd);
75+
}
76+
} else {
77+
printf(" [SKIP] open64 symbol absent\n");
78+
}
79+
80+
printf("== sanity: normal files remain accessible ==\n");
81+
errno = 0;
82+
fd = open(normal_file, O_RDONLY);
83+
check(fd >= 0, "open(normal) succeeds");
84+
if (fd >= 0) {
85+
close(fd);
86+
}
87+
88+
printf("\n%s (%d failure(s))\n", failures ? "TESTS FAILED" : "TESTS PASSED",
89+
failures);
90+
return failures ? 1 : 0;
91+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#!/bin/sh
2+
# Copyright 2025 HAProxy Technologies LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
#
7+
# Builds libblock_secrets.so and runs block_secrets_test.c under LD_PRELOAD.
8+
# Designed to run inside the musl/alpine build image so the test exercises the
9+
# same libc as production. Usage:
10+
# docker run --rm -v "$PWD/pkg/protection":/p:ro haproxytech/haproxy-alpine:3.2 \
11+
# sh -c 'apk add --no-cache build-base >/dev/null && cp /p/* /tmp && /tmp/block_secrets_test.sh'
12+
set -eu
13+
14+
SRC_DIR="$(cd "$(dirname "$0")" && pwd)"
15+
WORK="$(mktemp -d)"
16+
trap 'rm -rf "$WORK"' EXIT
17+
18+
cc -O2 -std=c11 -fPIC -shared -o "$WORK/libblock_secrets.so" \
19+
"$SRC_DIR/block_secrets.c" -ldl
20+
cc -O2 -std=c11 -o "$WORK/block_secrets_test" "$SRC_DIR/block_secrets_test.c" -ldl
21+
22+
# Protected dir with a "token" inside, plus a normal file outside it.
23+
BLOCKED_DIR="$WORK/secrets/kubernetes.io"
24+
mkdir -p "$BLOCKED_DIR"
25+
echo "secret-token" > "$BLOCKED_DIR/token"
26+
echo "data" > "$WORK/normal.txt"
27+
28+
BLOCK_SECRETS_PATH="$BLOCKED_DIR" \
29+
TEST_BLOCKED_FILE="$BLOCKED_DIR/token" \
30+
TEST_NORMAL_FILE="$WORK/normal.txt" \
31+
LD_PRELOAD="$WORK/libblock_secrets.so" \
32+
"$WORK/block_secrets_test"

hug/protection/haproxy_wrapper.c

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
/* See the License for the specific language governing permissions and */
1313
/* limitations under the License. */
1414

15+
/* Thin entrypoint that forces LD_PRELOAD=libblock_secrets.so and execs the
16+
real HAProxy, so the secrets-blocker is always loaded regardless of how the
17+
process is started. The Dockerfile grants this binary cap_net_bind_service. */
18+
1519
#define _GNU_SOURCE
1620
#include <errno.h>
1721
#include <stdio.h>

0 commit comments

Comments
 (0)