June 26, 2026
Why C++ Sanitizers Can’t Save Your Telecom Stack (And Where They Miss Bugs)
A practical guide for C++ engineers building carrier-grade telecom systems
By Akhil Pathania
6 min read
If you maintain a long-running SIP proxy, a media bridge, or a contact center routing engine in C++, you know the worst bugs are silent. A heap corruption that survives millions of SIP sessions before crashing. A data race that only surfaces during a 2 AM traffic spike. An uninitialized RTP buffer that leaks bytes from one session into the next.
C++ Sanitizers are compiler-level runtime tools that catch exactly these bugs — during development, before they ever reach production. This post covers the five main ones, how each works, and where each falls short.
How Sanitizers Work
When you compile with a sanitizer flag, the compiler inserts extra instructions around every relevant operation — every memory access, every arithmetic expression, every thread interaction. At runtime, a shadow memory region tracks the state of your program's memory. When something illegal happens, the sanitizer prints a full stack trace and aborts.
They are not static analysis. They only fire on code paths that actually execute. A clean sanitizer run means nothing if your tests are shallow. But paired with good integration tests or a fuzzier, they find bugs that weeks of code review would miss.
Address Sanitizer (ASAN)
What it catches: heap buffer overflow, stack buffer overflow, use-after-free, use-after-scope, double-free, invalid free.
How it works: ASAN maps every 8 bytes of program memory to 1 shadow byte that tracks whether those bytes are accessible. It places poisoned "red zones" around heap allocations and on freed memory. Any access that hits a red zone triggers a report.
Enable it:
g++ -fsanitize=address -fno-omit-frame-pointer -g -O1 -o sip_proxy sip_proxy.cppg++ -fsanitize=address -fno-omit-frame-pointer -g -O1 -o sip_proxy sip_proxy.cppTelecom example — SIP header parser overflow:
void parse_via_branch(const char* header, char* out, size_t out_size) {
const char* start = strstr(header, "branch=");
if (!start) return;
start += 7;
// BUG: Measures the length of the remaining string until a null terminator '\0'.
// If an attacker sends a massive branch value (e.g., 500 bytes), 'len' will be 500.
size_t len = strlen(start);
// BUG: 'out_size' (64 bytes) is completely ignored here.
// 'memcpy' blindly copies 'len' bytes into the 'out' buffer.
// If 'len' (500) > 'out_size' (64), it writes past the boundaries of the destination buffer.
// Also, it fails to append a null terminator ('\0') to the destination string.
memcpy(out, start, len); // BUG: no bounds check
// ASAN catches this immediately
// WHY ASAN CATCHES THIS IMMEDIATELY:
// ASan places "redzones" (poisoned memory zones) around the 'branch' stack buffer.
// The moment memcpy writes even 1 byte past the 64-byte boundary into the redzone,
// ASan detects the illegal access and immediately terminates the program with a
// "Stack-buffer-overflow" error report and a full call stack trace.
}
void handle_register(const char* sip_msg) {
char branch[64]; // stack buffer
parse_via_branch(sip_msg, branch, sizeof(branch));
}void parse_via_branch(const char* header, char* out, size_t out_size) {
const char* start = strstr(header, "branch=");
if (!start) return;
start += 7;
// BUG: Measures the length of the remaining string until a null terminator '\0'.
// If an attacker sends a massive branch value (e.g., 500 bytes), 'len' will be 500.
size_t len = strlen(start);
// BUG: 'out_size' (64 bytes) is completely ignored here.
// 'memcpy' blindly copies 'len' bytes into the 'out' buffer.
// If 'len' (500) > 'out_size' (64), it writes past the boundaries of the destination buffer.
// Also, it fails to append a null terminator ('\0') to the destination string.
memcpy(out, start, len); // BUG: no bounds check
// ASAN catches this immediately
// WHY ASAN CATCHES THIS IMMEDIATELY:
// ASan places "redzones" (poisoned memory zones) around the 'branch' stack buffer.
// The moment memcpy writes even 1 byte past the 64-byte boundary into the redzone,
// ASan detects the illegal access and immediately terminates the program with a
// "Stack-buffer-overflow" error report and a full call stack trace.
}
void handle_register(const char* sip_msg) {
char branch[64]; // stack buffer
parse_via_branch(sip_msg, branch, sizeof(branch));
}A crafted SIP REGISTER with a long branch parameter overflows branch[64] silently in production. ASAN catches it on the first replay.
Important: ASAN is blind to custom pool allocators unless you annotate them with ASAN_POISON_MEMORY_REGION / ASAN_UNPOISON_MEMORY_REGION. Many telecom codebases use slab allocators for SIP dialogs or RTP sessions — instrument them or ASAN has no visibility into those regions.
Thread Sanitizer (TSAN)
What it catches: data races, mutex use-after-destroy, lock order inversions.
How it works: TSAN uses a vector clock algorithm. Every thread and every memory location carries a logical timestamp. On every access, TSAN checks whether a happens-before relationship exists between the writing thread and the reading thread. If not, it reports a race.
Enable it:
g++ -fsanitize=thread -fno-omit-frame-pointer -g -O1 -o agent_router router.cpp
# Cannot be combined with ASAN — they use incompatible shadow memory layouts.g++ -fsanitize=thread -fno-omit-frame-pointer -g -O1 -o agent_router router.cpp
# Cannot be combined with ASAN — they use incompatible shadow memory layouts.Telecom example — agent state race in a routing engine:
struct AgentSession {
bool is_available; // plain bool — not atomic, not locked
uint32_t active_calls;
};
// Thread 1: call arrival
void on_call_arrived(AgentSession* s) {
if (s->is_available) { // READ — no lock
s->active_calls++; // READ-MODIFY-WRITE — no lock
s->is_available = false;
}
}
// Thread 2: agent logout (different thread)
void on_agent_logout(AgentSession* s) {
s->is_available = false; // WRITE — DATA RACE with Thread 1
}struct AgentSession {
bool is_available; // plain bool — not atomic, not locked
uint32_t active_calls;
};
// Thread 1: call arrival
void on_call_arrived(AgentSession* s) {
if (s->is_available) { // READ — no lock
s->active_calls++; // READ-MODIFY-WRITE — no lock
s->is_available = false;
}
}
// Thread 2: agent logout (different thread)
void on_agent_logout(AgentSession* s) {
s->is_available = false; // WRITE — DATA RACE with Thread 1
}Fix: use std::atomic<bool> and std::atomic<uint32_t>, plus a mutex for compound operations that need both fields to be consistent.
Note: TSAN carries 5–15x CPU overhead. Use it on focused concurrency tests, not full traffic replays.
Memory Sanitizer (MSAN)
What it catches: use of uninitialized memory — reading stack or heap memory before it has been written.
How it works: MSAN tracks a shadow bit for every byte. When memory is allocated, it is marked uninitialized. When it is written, the bit clears. When an uninitialized value influences a branch or gets passed to a function, MSAN reports.
Enable it (Clang only):
clang++ -fsanitize=memory -fsanitize-memory-track-origins=2 \
-fno-omit-frame-pointer -g -O1 -o rtp_processor rtp.cppclang++ -fsanitize=memory -fsanitize-memory-track-origins=2 \
-fno-omit-frame-pointer -g -O1 -o rtp_processor rtp.cppTelecom example — uninitialized SSRC in an RTP header:
struct RtpHeader {
uint8_t version_flags;
uint8_t marker_pt;
uint16_t sequence_number;
uint32_t timestamp;
uint32_t ssrc; // synchronization source
};
RtpHeader build_first_packet(uint8_t pt, uint32_t ts) {
RtpHeader hdr; // stack allocated, NOT zero-initialized
hdr.version_flags = 0x80;
hdr.marker_pt = 0x80 | pt;
hdr.sequence_number = htons(1);
hdr.timestamp = htonl(ts);
// ssrc is never set — MSAN catches this when it's serialized
return hdr;
}struct RtpHeader {
uint8_t version_flags;
uint8_t marker_pt;
uint16_t sequence_number;
uint32_t timestamp;
uint32_t ssrc; // synchronization source
};
RtpHeader build_first_packet(uint8_t pt, uint32_t ts) {
RtpHeader hdr; // stack allocated, NOT zero-initialized
hdr.version_flags = 0x80;
hdr.marker_pt = 0x80 | pt;
hdr.sequence_number = htons(1);
hdr.timestamp = htonl(ts);
// ssrc is never set — MSAN catches this when it's serialized
return hdr;
}A leftover ssrc from the stack can accidentally match another active stream, causing audio corruption in a conference bridge — silent and very hard to reproduce without MSAN.
Caveat: MSAN requires every library your binary links against to also be compiled with MSAN. Pre-built third-party SIP or codec libraries break this chain, making MSAN impractical for some projects. Valgrind's Memcheck is a slower but drop-in alternative that does not have this constraint.
UndefinedBehaviorSanitizer (UBSAN)
What it catches: signed integer overflow, null pointer dereference, misaligned access, invalid enum values, division by zero, strict aliasing violations.
Enable it:
g++ -fsanitize=undefined -fno-omit-frame-pointer -g -O1 -o codec_handler codec.cpp
# Combine freely with ASAN: -fsanitize=address,undefinedg++ -fsanitize=undefined -fno-omit-frame-pointer -g -O1 -o codec_handler codec.cpp
# Combine freely with ASAN: -fsanitize=address,undefinedTelecom example — strict aliasing violation in a packet parser:
This is the most common UBSAN finding in protocol parsing code:
// Common but technically undefined behavior
void parse_rtp(const uint8_t* buf) {
const RtpHeader* hdr = reinterpret_cast<const RtpHeader*>(buf); // UB
uint32_t ssrc = ntohl(hdr->ssrc);
}
// Correct: memcpy preserves the aliasing guarantee.
// The compiler generates identical machine code anyway.
void parse_rtp_safe(const uint8_t* buf) {
RtpHeader hdr;
memcpy(&hdr, buf, sizeof(RtpHeader)); // no aliasing violation
uint32_t ssrc = ntohl(hdr.ssrc);
}// Common but technically undefined behavior
void parse_rtp(const uint8_t* buf) {
const RtpHeader* hdr = reinterpret_cast<const RtpHeader*>(buf); // UB
uint32_t ssrc = ntohl(hdr->ssrc);
}
// Correct: memcpy preserves the aliasing guarantee.
// The compiler generates identical machine code anyway.
void parse_rtp_safe(const uint8_t* buf) {
RtpHeader hdr;
memcpy(&hdr, buf, sizeof(RtpHeader)); // no aliasing violation
uint32_t ssrc = ntohl(hdr.ssrc);
}Under -O3, the compiler assumes aliasing violations never happen and may eliminate or reorder reads — producing silent logic bugs, not crashes.
Also watch for: signed overflow in codec priority scores or payload length calculations. UBSAN will catch these; a bare -O2 build silently produces wrong results.
LeakSanitizer (LSAN)
What it catches: heap memory that is still allocated at program exit and unreachable from any live root (stack, global, register).
LSAN is built into ASAN. Enable it via:
ASAN_OPTIONS="detect_leaks=1" ./sip_proxyASAN_OPTIONS="detect_leaks=1" ./sip_proxyOr standalone (lower overhead, no ASAN):
g++ -fsanitize=leak -fno-omit-frame-pointer -g -o sip_proxy sip_proxy.cppg++ -fsanitize=leak -fno-omit-frame-pointer -g -o sip_proxy sip_proxy.cppTelecom example: A SIP transaction object is allocated on INVITE but not freed when a 503 triggers a retry on an alternate path. Ten leaks per day is invisible. Five thousand leaks per hour during a network partition exhausts the heap in under an hour.
Use a suppression file for intentional singletons that are never freed:
# lsan.supp
leak:PlatformConfigSingleton::initialize
leak:TelephonyLoggerBackend::create_instance# lsan.supp
leak:PlatformConfigSingleton::initialize
leak:TelephonyLoggerBackend::create_instanceCombining Sanitizers
Combination Works?
ASAN + UBSAN ✅ Best default for protocol code
TSAN + UBSAN ✅ Best for concurrency testing
ASAN + LSAN ✅ LSAN is included in ASAN
ASAN + TSAN ❌ Incompatible shadow memory
MSAN + ASAN ❌ Incompatible shadow memoryCombination Works?
ASAN + UBSAN ✅ Best default for protocol code
TSAN + UBSAN ✅ Best for concurrency testing
ASAN + LSAN ✅ LSAN is included in ASAN
ASAN + TSAN ❌ Incompatible shadow memory
MSAN + ASAN ❌ Incompatible shadow memoryPractical strategy: maintain two test builds — one with -fsanitize=address, undefined for correctness, one with -fsanitize=thread,undefined for concurrency.
Where Sanitizers Miss Bugs
This matters as much as knowing what they catch.
Untested code paths. Sanitizers are runtime tools. If a code path is never executed in your test suite, it is invisible to them. Fuzzing your SIP and SDP parsers with libFuzzer or AFL++ — both of which integrate naturally with ASAN — closes this gap.
Custom allocators. Pool allocators and slab allocators for SIP dialogs or RTP sessions are opaque to ASAN unless you add explicit poison/unpoison annotations. This is a significant blind spot in many long-lived telecom codebases.
Unsigned integer overflow. UBSAN only catches signed overflow. Unsigned overflow is defined behavior in C++ (wraps modulo 2^N). Sequence number wraparound bugs in RTP anti-replay logic are silent under UBSAN.
Logic and semantic bugs. Routing the call to the wrong agent, computing the wrong RTP timestamp increment, accepting a duplicate sequence number — these are not memory safety bugs and no sanitizer will catch them. They require unit tests with domain-specific correctness assertions.
TSAN races needing specific timing. TSAN's shadow memory records only recent accesses. A race requiring a very specific three-thread interleaving with long gaps may evade detection. Stress tests that amplify thread interleaving (heavy load, sched_yield injection) improve coverage.
Shared memory IPC. TSAN only sees user-space accesses. Any state shared across processes via shm_open or shmget is invisible to it. Design explicit protocols (in-SHM mutex, sequence counters) to protect this.
Inline assembly and SIMD intrinsics. SSE/AVX codec inner loops written in intrinsics or hand-rolled assembly are not instrumented. ASAN will not catch an overread in a SIMD G.711 decode loop that reads 16 bytes from a 14-byte buffer.
The Right Mindset
A clean sanitizer run on a shallow test suite is meaningless. A clean sanitizer run after replaying tens of thousands of real SIP calls, injecting malformed messages, and stress-testing concurrent session teardown — that is meaningful.
Sanitizers multiply the value of your existing test suite. They do not replace it.
Add -fsanitize=address,undefined to your CI build today. Replay your longest integration test trace. You will almost certainly find something.
Senior C++ engineer with telecom and contact center systems background. Views are personal.