Fix crashes in the Redis xbit storage path - #106
Open
atobar-quadrant wants to merge 2 commits into
Open
Conversation
added 2 commits
July 2, 2026 14:09
All in src/redis.c, all standard hiredis-usage errors. Verified against a live Redis 7 with AUTH and against a fault-injection listener that accepts TCP connections and immediately drops them (simulating the production Redis connectivity flaps): * NULL-check the AUTH reply in Redis_Reader_Connect() and Redis_Writer_Connect(). redisCommand() returns NULL when the connection drops mid-command, so a connectivity flap during (re)auth dereferenced NULL and crashed the engine. Reproduced: stock build segfaults (SIGSEGV) on the first dropped connection; patched build survives. The retry is a loop with the existing 2-second backoff (8 attempts in 15s), not recursion, so no stack growth and no connection hammering. A non-NULL reply that is not OK still aborts (genuinely wrong password), as before. * Free the AUTH reply objects (previously leaked on every reconnect), free-and-NULL failed writer contexts (leaked per retry), and NULL the reader context after redisFree() so the retry-loop condition does not read freed memory. * Redis_Reader(): split the command on spaces and use redisCommandArgv() instead of passing the caller's command string as the printf-style format. A '%' in a key or stored payload was interpreted as a printf conversion (undefined behavior/crash). Likewise copy replies with a "%s" format instead of using the reply as the format string. * Redis_Reader(): drop the str[reply->len] = '\0' writes. reply->len is bounded by message_buffer_size * 2 (writer side) while str is message_buffer_size (reader side), so large correlation payloads wrote past the end of the destination buffer. snprintf() already bounds and NUL-terminates. Also guard element[0]->str before use. End-to-end verification with xbit rules (set + isset) against live Redis: the xbit is stored with the setting event's JSON and TTL, the isset GET retrieves it, and the correlated rule fires with the original (setting) event as its correlation data. Context: xbit storage was rolled back fleet-wide from redis to mmap on 2026-06-25..29 because of these crashes, which degraded correlated alerts (self-referential correlation blocks, xbits lost on restart). This patch is intended to make redis xbit storage safe to re-enable. Needs a canary build/deploy on one sensor before fleet rollout.
Fixes the build on toolchains where inttypes.h is not pulled in transitively (only surfaces with --disable-lognorm).
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why this PR exists
Sagan can crash when xbit storage is set to
redisand the Redis connection has problems. The crash forces deployments off Redis storage and onto mmap, which costs real functionality: with mmap the original event that set an xbit is never saved, so correlated alerts (for example a "login after brute force" rule usingxbits: isset) carry their own event as the correlation data instead of the event that set the bit, and a Sagan restart erases all xbits, so correlations that span a restart never fire.This PR fixes the crash, plus three related memory bugs in the same file. All changes are in
src/redis.c. A second commit adds one#includeline tosrc/config-yaml.cto fix a build error that only appears with--disable-lognorm.The explanations below assume no C background, so reviewers who mostly work in other languages can follow what changed and why.
Bug 1: the crash (reading an answer that never arrived)
When Sagan talks to Redis it uses a library called hiredis. You ask Redis a question with
redisCommand()and get back an answer object calledreply. If the connection dies while the question is in flight, hiredis has no answer to give you, so it returnsNULL, which is C for "nothing".The old login code assumed an answer always comes back and immediately read a field inside it:
Reading a field of "nothing" makes the operating system kill the process on the spot. That is the segfault. The
AUTH(login) step runs every time Sagan connects or reconnects to Redis, so a connectivity flap lands on exactly this line. This is why the crash shows up together with Redis connection trouble and not at any other time.The fix checks for the missing answer, drops the dead connection, waits 2 seconds, and retries the whole connect-and-login sequence:
The 2 second pause matters. Without it, Sagan would retry tens of thousands of times per second during an outage, flooding the log and hammering the Redis host. With it, an outage costs one log line every 2 seconds and the engine reconnects by itself when Redis comes back.
A genuinely wrong password still shuts Sagan down with the same "Authentication failure" error as before. The retry only covers the case where Redis went away mid-login. The same fix is applied to both connection paths (the "reader" and the "writer").
Bug 2: commands and data were used as templates
Some C functions take a template string where
%marks a placeholder to fill in, the wayAUTH %sabove fills in the password.Redis_Reader()handed the Redis command, and later the data coming back from Redis, to these functions as the template itself:If the stored data contains a
%character, the function treats it as a placeholder instruction and goes looking for a value that was never provided. It reads whatever garbage happens to be in memory, which corrupts the result or crashes the process. The values stored in these keys include log payloads, which are outside input, so this is a security problem as well as a stability one.The fix sends commands as a list of words through
redisCommandArgv(), which has no template step at all, and copies replies through an explicit"%s"template so the data is always treated as plain text:One warning for anyone comparing this against other hiredis code: the tempting one-line fix,
redisCommand(c, "%s", redis_command), does not work. hiredis then sends the whole string ("GET sagan:...") to Redis as a single word, Redis rejects it as an unknown command, and every xbit lookup silently returns empty. I hit this during testing.redisCommandArgv()is the correct API.Bug 3: writing one byte past the end of a buffer
Sagan reserves a fixed amount of memory for the reply it copies out of Redis. The writer side stores values up to twice that size (
message_buffer_size * 2on write,message_buffer_sizeon read). After copying, the old code wrote a string terminator at the position matching the reply's full length:For a large correlation payload that terminator lands outside the reserved memory, on top of whatever is stored next. Sometimes nothing visible happens. Sometimes the process dies later in an unrelated place, which is the kind of intermittent crash that is nearly impossible to trace from a core dump.
snprintf()already limits the copy to the buffer size and terminates it, so the extra write is simply removed.Bug 4: sloppy cleanup in the reconnect loops
Two smaller problems in the retry loops that only matter during connection trouble. The reader loop released a failed connection back to the system and then read its status through the loop condition, touching memory it had already given back. The writer loop never released failed connection attempts at all, so every retry during an outage leaked a little memory. Both paths now release the connection and clear the pointer before retrying.
How this was tested
Two setups, both scripted and repeatable.
First, crash reproduction with fault injection. A small Python script pretends to be Redis: it accepts the TCP connection and hangs up immediately, which is the same shape as a real connectivity flap between connect and login. Pointed at it with a password configured:
Redis 'reader' disconnected during AUTH! Sleeping for 2 seconds!and keeps retrying at 2 second intervals (8 attempts in a 15 second run), then connects normally once a real Redis appears on that portSecond, end to end against a real Redis 7 with
requirepassset. Using one rule withxbits: setand one withxbits: isset:sagan:<cluster>:<xbit_name>:<ip>with the expected expiryThe debug log from that run, trimmed:
That reply is the original event coming back as the correlation for the second alert, which is the behavior
xbit-storage: redisexists to provide. The test harness (fake Redis script, minimal sagan.yaml, test rules and events) is small and I am happy to attach it if useful.One build note
A Sagan binary built without the hiredis development library accepts
xbit-storage: redisin sagan.yaml and then silently uses mmap anyway, because the fatal "redis requested but not available" check is itself compiled out in that case. When validating a build of this branch, confirm hiredis is actually linked (ldd sagan | grep hiredis) before trusting that Redis storage is in effect.