Stack Buffer Overflow in CSS Selector Parsing: A Critical C Vulnerability Fixed
Severity: 🔴 Critical | CWE: CWE-120 (Buffer Copy Without Checking Size of Input) | File:
lib/css/src/selector.c
Introduction
Buffer overflows are one of the oldest and most dangerous vulnerability classes in software security. Despite being well-understood for decades, they continue to appear in production codebases — and when they do, the consequences can be severe. A recently patched vulnerability in a CSS selector parsing library serves as a timely reminder of why strcpy() is considered one of C's most dangerous standard library functions.
This post breaks down the vulnerability, explains how it could be exploited, and walks through the fix — giving you the knowledge to recognize and prevent this class of bug in your own code.
The Vulnerability Explained
What Happened?
Inside lib/css/src/selector.c, a function was responsible for constructing a "full name" string for a CSS selector node. It did this by concatenating several fields — type, id, classes, and status — into a single fixed-size stack buffer using multiple calls to strcpy() and strcat().
Here's a simplified representation of what the vulnerable code looked like:
// VULNERABLE CODE (illustrative example)
void build_selector_fullname(SelectorNode *node) {
char fullname[256]; // Fixed-size stack buffer
strcpy(fullname, node->type); // No bounds check
strcat(fullname, node->id); // No bounds check
strcat(fullname, node->classes); // No bounds check
strcat(fullname, node->status); // No bounds check
// Use fullname for further processing...
}
The critical problem: there is no bounds checking on any of these copy operations. The buffer fullname is allocated with a fixed size (e.g., 256 bytes) on the stack. If the combined length of type + id + classes + status exceeds that size, the function happily writes past the end of the buffer — directly into adjacent stack memory.
Why Is This So Dangerous?
When data is written beyond the bounds of a stack-allocated buffer, it can overwrite:
- The saved return address — the address the CPU jumps to when the function returns
- Saved frame pointers — used by the debugger and calling convention
- Local variables of the calling function — potentially corrupting program logic
- Stack canary values — security mitigations designed to detect this exact attack
By carefully crafting input that overflows the buffer with a specific payload, an attacker can redirect execution to arbitrary code — a technique known as return-oriented programming (ROP) or classic stack smashing.
How Could It Be Exploited?
Consider the attack scenario:
- An attacker crafts a malicious CSS stylesheet with an extremely long selector — for example, a class name that is 500 characters long.
- The application parses this stylesheet and passes the selector fields to
build_selector_fullname(). - The
strcpy/strcatcalls write 500+ bytes into a 256-byte buffer. - The excess bytes overwrite the return address on the stack.
- When the function returns, the CPU jumps to attacker-controlled code.
- The attacker achieves arbitrary code execution in the context of the running process.
This is classified under CWE-120: Buffer Copy Without Checking Size of Input ('Classic Buffer Overflow'), and it's rated Critical because successful exploitation can lead to full system compromise.
Real-World Impact
- Arbitrary code execution on the host system
- Privilege escalation if the process runs with elevated permissions
- Data exfiltration — reading sensitive memory contents
- Denial of service — crashing the application by corrupting the stack
- Supply chain risk — if this library is embedded in other software, all downstream consumers are affected
The Fix
What Changed?
The fix replaces the unbounded strcpy()/strcat() calls with their length-aware, bounds-checking counterparts: strncpy() and strncat() — or ideally, a safer pattern using snprintf(), which provides explicit size control and null-termination guarantees in a single call.
Here's what a safe version of this function looks like:
// SAFE CODE (illustrative example of the fix)
void build_selector_fullname(SelectorNode *node) {
char fullname[256];
size_t remaining = sizeof(fullname) - 1;
fullname[0] = '\0'; // Ensure null-terminated start
strncat(fullname, node->type, remaining);
remaining -= strlen(node->type) < remaining ? strlen(node->type) : remaining;
strncat(fullname, node->id, remaining);
remaining -= strlen(node->id) < remaining ? strlen(node->id) : remaining;
strncat(fullname, node->classes, remaining);
remaining -= strlen(node->classes) < remaining ? strlen(node->classes) : remaining;
strncat(fullname, node->status, remaining);
}
Or, even cleaner and less error-prone using snprintf():
// PREFERRED SAFE PATTERN using snprintf()
void build_selector_fullname(SelectorNode *node) {
char fullname[256];
int written = snprintf(
fullname,
sizeof(fullname),
"%s%s%s%s",
node->type,
node->id,
node->classes,
node->status
);
if (written < 0 || (size_t)written >= sizeof(fullname)) {
// Handle truncation or error — log, return early, or abort
handle_error("Selector fullname truncated or encoding error");
return;
}
// Safely use fullname...
}
Why snprintf() Is the Right Tool Here
| Function | Bounds Checking | Null Termination | Error Detection |
|---|---|---|---|
strcpy() |
❌ None | ✅ Yes | ❌ None |
strcat() |
❌ None | ✅ Yes | ❌ None |
strncpy() |
✅ Yes | ⚠️ Not guaranteed | ❌ Limited |
strncat() |
✅ Yes | ✅ Yes | ❌ Limited |
snprintf() |
✅ Yes | ✅ Yes | ✅ Returns length |
snprintf() is preferred because:
- It enforces a hard maximum output size including the null terminator
- It returns the number of bytes that would have been written, letting you detect truncation
- It handles the entire concatenation in one readable call, reducing the chance of off-by-one errors
Prevention & Best Practices
1. Never Use strcpy() or strcat() on Untrusted Input
These functions have no concept of buffer size. Treat them as deprecated for any code that handles external data. Many organizations enforce this through static analysis rules.
// ❌ NEVER do this with external input
strcpy(dest, user_controlled_input);
// ✅ Always do this
snprintf(dest, sizeof(dest), "%s", user_controlled_input);
2. Validate Input Length Before Processing
Before passing user-controlled strings into any buffer operation, validate their length:
if (strlen(node->type) + strlen(node->id) + strlen(node->classes) + strlen(node->status) >= sizeof(fullname)) {
return ERROR_INPUT_TOO_LONG;
}
3. Use Compiler Hardening Flags
Modern compilers offer several protections against buffer overflows:
# Enable stack canaries
gcc -fstack-protector-strong
# Enable address space layout randomization support
gcc -fpie -pie
# Enable overflow detection (adds runtime checks)
gcc -D_FORTIFY_SOURCE=2 -O2
# Enable all warnings
gcc -Wall -Wextra -Werror
4. Use Static Analysis Tools
Integrate static analysis into your CI/CD pipeline to catch these issues before they reach production:
- Clang Static Analyzer — free, catches many memory bugs
- Coverity — free for open source projects
- PVS-Studio — commercial, very thorough
- Flawfinder — specifically flags dangerous C functions like
strcpy - cppcheck — open source C/C++ static analyzer
Flawfinder, for example, would have flagged this code immediately:
[lib/css/src/selector.c:84] (error) strcpy() called with destination buffer
of insufficient size — potential buffer overflow (CWE-120)
5. Consider Memory-Safe Alternatives
For new projects or major refactors, consider languages or libraries that eliminate this class of bug entirely:
- Rust — ownership model prevents buffer overflows at compile time
- C++ with
std::string— automatic memory management - Safe C libraries — like SafeStr or Microsoft's
strsafe.h
6. Fuzz Test Your Parsers
Parsers are a prime target for buffer overflow attacks because they process external, attacker-controlled data. Use fuzzing to automatically generate edge-case inputs:
# Using AFL++ to fuzz a CSS parser
afl-fuzz -i input_corpus/ -o findings/ -- ./css_parser @@
Fuzzing would likely have discovered this vulnerability by generating extremely long selector strings.
Security Standards Reference
- CWE-120: Buffer Copy Without Checking Size of Input
- CWE-121: Stack-based Buffer Overflow
- OWASP: Buffer Overflow: OWASP guidance on this vulnerability class
- SEI CERT C Coding Standard: STR31-C: Guarantee sufficient storage for strings
Conclusion
This vulnerability is a textbook example of why strcpy() earned its reputation as one of C's most dangerous functions. A few lines of code, written without size awareness, created a critical security hole capable of enabling arbitrary code execution.
The key takeaways from this fix:
strcpy()andstrcat()are unsafe for any input you don't fully control — replace them withsnprintf()orstrncat()with explicit size limits.- Fixed-size stack buffers + unbounded copies = stack overflow — always calculate the maximum possible input size before choosing a buffer size.
- Parsers deserve extra scrutiny — they process external data by definition, making them high-value targets.
- Defense in depth matters — compiler hardening, static analysis, and fuzzing work together to catch what code review misses.
Buffer overflows have been on the OWASP Top 10 and in security advisories since the 1980s. They persist because C gives developers enormous power with very little guardrails. The antidote is discipline, tooling, and a healthy respect for the damage a few unchecked bytes can do.
Write bounds-aware code. Fuzz your parsers. Trust no input.
This vulnerability was identified and patched as part of an automated security review process. Security fixes like this one are a normal, healthy part of software development — what matters is catching them early and learning from them.
Found a security issue in your codebase? Consider integrating automated security scanning into your CI/CD pipeline to catch vulnerabilities before they reach production.