The more I dug into C++ codebases, the more I noticed a recurring assumption: developers who think that switching to smart pointers and STL containers means they’re safe from memory bugs. C++ adds RAII, smart pointers, containers, and type-safe abstractions on top of C’s manual memory model, and these features genuinely eliminate many of C’s most common vulnerabilities, std::string prevents buffer overflows, std::unique_ptr prevents memory leaks, and std::vector provides bounds-checked access via .at(). But C++ also introduces new attack surfaces that turn out to be even trickier to spot: dangling references from moved-from objects, iterator invalidation, implicit conversions in template code, and the false sense of security that comes from using “safe” abstractions incorrectly. In this post, I want to cover the C++-specific anti-patterns that survive code review because they look correct to developers who trust the standard library.

Smart Pointer Pitfalls

shared_ptr Cycles: Memory Leaks That Become Denial of Service

std::shared_ptr uses reference counting. If two objects hold shared_ptr references to each other, the reference count never reaches zero and the memory is never freed. This can turn into a denial-of-service vector in long-running services.

#include <memory>
#include <string>
#include <iostream>

struct Node {
    std::string name;
    std::shared_ptr<Node> next;

    Node(const std::string& n) : name(n) {
        std::cout << "Created: " << name << std::endl;
    }
    ~Node() {
        std::cout << "Destroyed: " << name << std::endl;
    }
};

void create_cycle() {
    auto a = std::make_shared<Node>("A");
    auto b = std::make_shared<Node>("B");
    a->next = b;
    b->next = a; // Cycle: A -> B -> A
    // When create_cycle returns, a and b go out of scope
    // But the reference count for both is still 1 (held by the other node)
    // Neither destructor runs, memory leak
}

In a server that processes requests, each request that creates a cycle leaks memory. Over time, the process exhausts available memory, denial of service. I came across a write-up about this happening in a graph-processing service that built node relationships per request. The fix is std::weak_ptr for back-references:

struct Node {
    std::string name;
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev; // Does not increment reference count
};

unique_ptr and Use-After-Move

std::unique_ptr transfers ownership on move. After a move, the source pointer is null. Using the moved-from pointer is undefined behaviour in practice (it dereferences null). This pattern shows up in session management code, and it’s always a headache to debug.

#include <memory>
#include <vector>
#include <iostream>

struct Session {
    std::string token;
    std::string username;
};

void process_sessions(std::vector<std::unique_ptr<Session>>& sessions) {
    for (auto& session : sessions) {
        if (session->username == "admin") {
            auto admin_session = std::move(session); // session is now null
            log_admin_access(admin_session.get());
        }
    }
    // Later iteration over sessions will dereference null pointers
    for (auto& session : sessions) {
        if (session) { // Must check for null after moves
            update_session(session.get());
        }
    }
}

The first loop moves admin sessions out of the vector, leaving null unique_ptrs. If the second loop doesn’t check for null, it crashes. In security-critical code, the crash may be exploitable if it occurs during authentication or authorization. I’ve run into this pattern in code reviews, a session cleanup routine that would crash the auth service under specific conditions.

Comparison: Rust’s Ownership

Rust’s ownership system prevents use-after-move at compile time:

fn process_sessions(sessions: &mut Vec<Session>) {
    for session in sessions.iter() {
        if session.username == "admin" {
            // Cannot move out of a borrowed reference
            // let admin_session = *session; // Compile error
        }
    }
}

Rust doesn’t allow moving a value out of a borrowed container. The programmer must use Vec::remove, Vec::swap_remove, or std::mem::take with explicit handling of the empty slot. It’s the kind of compile-time guarantee that would eliminate an entire class of bugs in C++.

Iterator Invalidation

Modifying a container while iterating over it invalidates iterators. In C++, using an invalidated iterator is undefined behaviour, it may read garbage, crash, or silently produce wrong results. This is one of those bugs that can take hours to track down because the behaviour is non-deterministic.

The Vulnerable Pattern

#include <vector>
#include <string>
#include <iostream>

struct User {
    std::string name;
    bool is_banned;
};

void remove_banned_users(std::vector<User>& users) {
    for (auto it = users.begin(); it != users.end(); ++it) {
        if (it->is_banned) {
            users.erase(it); // Invalidates it and all subsequent iterators
            // ++it in the loop header now increments an invalid iterator
        }
    }
}

erase returns an iterator to the next element, but the loop increments the (now invalid) iterator. This skips elements and may read past the end of the vector. I’ve seen this in user management code where banned users were supposed to be removed from an access list, the bug meant some banned users stayed in the list. The fix:

void remove_banned_users(std::vector<User>& users) {
    auto it = users.begin();
    while (it != users.end()) {
        if (it->is_banned) {
            it = users.erase(it); // erase returns iterator to next element
        } else {
            ++it;
        }
    }
}

Or use the erase-remove idiom, which is cleaner:

void remove_banned_users(std::vector<User>& users) {
    users.erase(
        std::remove_if(users.begin(), users.end(),
            [](const User& u) { return u.is_banned; }),
        users.end()
    );
}

Comparison: Java’s ConcurrentModificationException

Java’s ArrayList throws ConcurrentModificationException at runtime if the list is modified during iteration:

// Java: Throws ConcurrentModificationException
for (User user : users) {
    if (user.isBanned()) {
        users.remove(user); // Throws at runtime
    }
}

Java’s approach is safer, it fails loudly instead of silently producing undefined behaviour. C++ silently corrupts the iteration, which makes the bug much harder to find.

Implicit Conversions and Narrowing

C++ inherits C’s implicit conversion rules and adds its own: converting constructors, conversion operators, and template argument deduction. These create silent type changes that bypass security checks. What I find particularly frustrating is that the code compiles without warnings on many configurations.

The Vulnerable Pattern: Signed/Unsigned Comparison

#include <vector>
#include <cstdint>
#include <iostream>

bool is_valid_index(int index, const std::vector<uint8_t>& data) {
    if (index < 0) return false;
    if (index >= data.size()) return false; // Warning: signed/unsigned comparison
    return true;
}

void process(const std::vector<uint8_t>& data, int offset) {
    if (is_valid_index(offset, data)) {
        std::cout << "Value: " << static_cast<int>(data[offset]) << std::endl;
    }
}

The comparison index >= data.size() compares a signed int with an unsigned size_t. If index is negative, the < 0 check catches it. But if the compiler optimises based on the assumption that index is non-negative after the first check, the signed-to-unsigned conversion in the second comparison may produce unexpected results on some platforms. The safe approach:

bool is_valid_index(int index, const std::vector<uint8_t>& data) {
    if (index < 0) return false;
    if (static_cast<size_t>(index) >= data.size()) return false;
    return true;
}

The Subtle Version: Implicit Conversion in Constructor

#include <string>
#include <iostream>

class SecureString {
    std::string data_;
public:
    SecureString(const std::string& s) : data_(s) {}
    SecureString(int length) : data_(length, '\0') {} // Intended: create string of given length

    bool operator==(const SecureString& other) const {
        return data_ == other.data_;
    }
};

void verify_token(const SecureString& expected, const SecureString& actual) {
    if (expected == actual) {
        std::cout << "Token valid" << std::endl;
    }
}

int main() {
    SecureString expected("secret-token-12345");
    verify_token(expected, 42); // Compiles! 42 is implicitly converted to SecureString(42)
    return 0;
}

The integer 42 is implicitly converted to SecureString(42), which creates a string of 42 null bytes. The comparison fails, but the implicit conversion means the function accepts an integer where a string was expected. In a more complex scenario, the implicit conversion could produce a value that passes the comparison. What surprised me when I first encountered this is that it compiles without any warning on default settings. The fix is explicit:

explicit SecureString(int length) : data_(length, '\0') {}
// Now: verify_token(expected, 42); // Compile error

Comparison: Go’s Strict Typing

Go doesn’t have implicit conversions between types:

// Go: No implicit conversion, compile error
var index int = -1
var size uint = 10
// if index >= size { } // Compile error: cannot compare int and uint
if index >= 0 && uint(index) >= size { } // Must convert explicitly

Dangling References from Temporary Objects

C++ creates temporary objects in many contexts: function return values, implicit conversions, and aggregate initialization. References to temporaries become dangling when the temporary is destroyed. These bugs can look perfectly reasonable at first glance.

The Vulnerable Pattern

#include <string>
#include <iostream>

const std::string& get_default_config() {
    return "default"; // Returns reference to temporary std::string
    // Temporary is destroyed when function returns, dangling reference
}

std::string_view get_username(const std::string& full_name) {
    std::string first = full_name.substr(0, full_name.find(' '));
    return first; // Returns string_view to local string, dangling on return
}

int main() {
    const std::string& config = get_default_config(); // Dangling reference
    std::cout << config << std::endl; // Undefined behavior

    std::string name = "John Doe";
    auto username = get_username(name); // Dangling string_view
    std::cout << username << std::endl; // Undefined behavior
}

string_view doesn’t own the underlying data. When the local std::string is destroyed, the string_view points to freed memory. This is C++’s version of use-after-free, but it looks safe because no raw pointers are visible. Here’s what clicked for me when studying this: string_view feels safe because it’s a “modern C++” feature, but it’s really just a pointer and a length with a nicer API.

Comparison: Rust’s Lifetime Annotations

Rust’s borrow checker prevents returning references to local variables:

// Rust: Compile error, cannot return reference to local variable
fn get_username(full_name: &str) -> &str {
    let first = &full_name[..full_name.find(' ').unwrap_or(full_name.len())];
    first // OK: borrows from full_name, which outlives the return
}

fn get_default_config() -> &str {
    // let s = String::from("default");
    // &s // Compile error: `s` does not live long enough
    "default" // OK: string literal has 'static lifetime
}

Rust’s lifetime system ensures that references always point to valid memory. C++ has no equivalent compile-time check, and that’s a gap that really stands out when you compare the two languages.

Virtual Dispatch and Type Confusion

C++ virtual functions use vtable pointers stored in the object. If an object’s memory is corrupted (e.g., through a buffer overflow), the vtable pointer can be overwritten to redirect virtual calls to attacker-controlled code. This is one of the classic exploitation techniques, and it’s well-documented in both CTF write-ups and real-world exploit analyses.

The Vulnerable Pattern

#include <cstring>
#include <iostream>

class Authenticator {
public:
    virtual bool verify(const char* token) {
        return strcmp(token, "valid-token") == 0;
    }
    virtual ~Authenticator() = default;
};

class RequestHandler {
    char buffer[64];
    Authenticator* auth;

public:
    RequestHandler(Authenticator* a) : auth(a) {
        memset(buffer, 0, sizeof(buffer));
    }

    void handle(const char* input) {
        strcpy(buffer, input); // Buffer overflow overwrites auth pointer
        if (auth->verify("check")) { // Virtual call through corrupted pointer
            std::cout << "Authenticated" << std::endl;
        }
    }
};

The strcpy overflow overwrites the auth pointer. The attacker controls the pointer value, which is then used for a virtual function call. The attacker points auth to a fake vtable that redirects verify to shellcode. This is a classic vtable hijacking attack, and it’s a good reminder of why strcpy has no place in C++ code when std::string exists.

Detection Strategies

Tool What It Catches Limitations
Clang-Tidy Use-after-move, iterator invalidation, implicit conversions, dangling references Static analysis; limited interprocedural reasoning
AddressSanitizer Buffer overflow, use-after-free, use-after-scope, double-free Runtime only; requires triggering inputs
UBSan Undefined behavior: null dereference, signed overflow, invalid casts Runtime only
cppcheck Memory leaks, null dereferences, buffer overflows, iterator invalidation Limited to simple patterns
Valgrind Memory leaks, use-after-free, uninitialized reads Slow; does not catch stack overflows
-Wall -Wextra -Wpedantic Signed/unsigned comparison, narrowing conversions, unused variables Compile-time only
Semgrep Pattern-based detection of dangerous function calls Limited C++ support

Manual Review Checklist

Here’s a checklist I’ve put together for C++ security reviews based on the patterns that come up most often:

  1. Search for std::move, verify the moved-from object is not used afterward. This is the top C++ heuristic.
  2. Search for erase inside loops, verify the iterator is updated from the return value.
  3. Search for string_view return types, verify the underlying data outlives the view. Don’t trust string_view just because it’s “modern.”
  4. Search for single-argument constructors, verify they are marked explicit. Implicit conversions are a silent killer.
  5. Search for strcpy, sprintf, strcat, replace with std::string operations or snprintf. There’s no excuse for these in C++.
  6. Search for reinterpret_cast and const_cast, both bypass type safety. Flag every instance.
  7. Search for raw new/delete, replace with std::make_unique or std::make_shared.
  8. Search for shared_ptr cycles, verify back-references use weak_ptr.

Remediation Patterns

Use RAII Consistently

// Bad: Manual resource management
FILE* f = fopen("data.txt", "r");
if (!f) return;
// ... processing that might throw or return early ...
fclose(f); // May never execute

// Good: RAII wrapper
#include <fstream>
std::ifstream f("data.txt");
if (!f.is_open()) return;
// f is automatically closed when it goes out of scope

Use .at() for Bounds-Checked Access

// Bad: No bounds check
int value = vec[index]; // Undefined behavior if index >= vec.size()

// Good: Bounds-checked access
try {
    int value = vec.at(index); // Throws std::out_of_range
} catch (const std::out_of_range& e) {
    // Handle error
}

Use span Instead of Raw Pointer + Length

#include <span>

// Bad: Raw pointer and separate length, easy to mismatch
void process(const uint8_t* data, size_t length);

// Good: span carries pointer and length together
void process(std::span<const uint8_t> data) {
    for (auto byte : data) {
        // Bounds-checked iteration
    }
}

std::span (C++20) provides a non-owning view with bounds information, eliminating the pointer/length mismatch class of bugs. It’s worth pushing for std::span adoption in any C++ codebase that deals with raw buffers.

Key Takeaways

  1. Smart pointers prevent leaks, not all memory bugs. shared_ptr cycles leak memory. unique_ptr use-after-move dereferences null. string_view dangles when the source is destroyed.
  2. Iterator invalidation is undefined behaviour. Modifying a container during iteration silently corrupts the program. Use the erase-remove idiom or update iterators from erase return values.
  3. Implicit conversions bypass type safety. Mark single-argument constructors explicit. Use static_cast instead of C-style casts. This is a quick win that prevents subtle bugs.
  4. string_view is a dangling reference waiting to happen. Never return string_view to a local std::string. Verify the underlying data outlives every string_view.
  5. C++ adds safety features on top of C’s unsafety. The safe features (std::string, std::vector, smart pointers) are opt-in. Raw pointers, strcpy, and manual new/delete are still available and still dangerous. The biggest risk is the assumption that “modern C++” means “safe C++.”
  6. Compile with sanitizers and warnings at maximum level. -Wall -Wextra -Wpedantic -fsanitize=address,undefined catches bugs that code review misses. It’s worth making this a non-negotiable requirement on any C++ project.