Null pointer dereference (CWE-476) is one of those bugs that shows up across every language, and the more I researched it for this post, the more I was struck by how much damage it can do depending on context. The consequences vary dramatically: C programs crash with a segfault (or worse, the kernel maps page zero and an attacker gets code execution), C++ invokes undefined behaviour that the compiler may optimise into literally anything, Go panics with a nil pointer dereference that kills the goroutine or the whole program, and Java throws a NullPointerException that can crash the app or leak stack traces to an attacker. MITRE ranks CWE-476 consistently in the top 25 most dangerous software weaknesses, and digging into the CVE data, that ranking is well deserved. I want to walk through C, C++, Go, and Java here, from the obvious unchecked malloc return to the subtle nil interface trap in Go and the conditional path where null silently propagates through multiple function calls.

Why Null Pointer Dereference Matters for Security

Here’s the thing, a null pointer dereference is not just a crash bug. Depending on the context, it can enable several real attack vectors:

  1. Denial of service: This is the most common impact. A null dereference crashes the process (C/C++) or the goroutine/thread (Go/Java). In a server, that means dropped connections, failed requests, or complete service outage. If an attacker can trigger the null dereference remotely, they’ve got a reliable DoS primitive.
  2. Information disclosure: In Java, an unhandled NullPointerException that propagates to the client reveals class names, method names, and line numbers in the stack trace. This kind of information is valuable for mapping an application’s internals before going deeper, it’s essentially free reconnaissance.
  3. Code execution (C/C++ on older or embedded systems): On systems where the zero page is mappable (older Linux kernels before mmap_min_addr, some embedded systems, some hypervisors), an attacker maps controlled data at address 0. When the program dereferences the null pointer, it reads the attacker’s data, including function pointers. The write-ups on this turning a simple null deref into full arbitrary code execution are genuinely eye-opening.
  4. Logic bypass: If a null check is missing on one path but present on another, an attacker may force execution down the unchecked path, bypassing authentication, authorization, or validation logic that depends on the object being non-null. I’ve run into this pattern hiding in auth middleware during code reviews.
  5. Cascading failures: In microservice architectures, a null dereference panic in one service triggers retries, timeouts, and cascading failures across dependent services. A single null pointer can take down an entire distributed system, it’s not theoretical, it shows up in post-mortems regularly.

The root cause is always the same: the program assumes a pointer or reference is valid without verifying it. The assumption fails when a function returns null to signal an error, when an optional field is absent, when a lookup misses, or when a race condition clears a pointer between the check and the use.

The Easy-to-Spot Version

C: Unchecked malloc Return

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
    char username[64];
    char role[32];
    int permissions;
} UserSession;

UserSession* create_session(const char *username, const char *role) {
    UserSession *session = malloc(sizeof(UserSession));
    // No null check, if malloc fails, every line below is a null dereference
    strncpy(session->username, username, sizeof(session->username) - 1);
    session->username[sizeof(session->username) - 1] = '\0';
    strncpy(session->role, role, sizeof(session->role) - 1);
    session->role[sizeof(session->role) - 1] = '\0';
    session->permissions = 0;
    return session;
}

int main() {
    UserSession *s = create_session("admin", "superuser");
    printf("Created session for %s\n", s->username);
    free(s);
    return 0;
}

malloc returns NULL when the system is out of memory or when the requested size exceeds the allocator’s limits. The code immediately dereferences the return value without checking. On a desktop system, malloc rarely fails, so the bug hides in testing. On an embedded system with limited memory, or when an attacker can exhaust memory (say, by opening thousands of connections), malloc returns NULL and the strncpy writes to address 0, a segfault, or worse.

Every SAST tool flags an unchecked malloc return. The fix is a null check immediately after allocation, straightforward, but these still show up in production code regularly.

Java: Unchecked Map Lookup

import java.util.HashMap;
import java.util.Map;

public class ConfigService {
    private Map<String, String> config = new HashMap<>();

    public ConfigService() {
        config.put("db.host", "localhost");
        config.put("db.port", "5432");
    }

    public String getConnectionString() {
        String host = config.get("db.host");
        String port = config.get("db.port");
        String name = config.get("db.name");
        // name is null, "db.name" is not in the map
        return "jdbc:postgresql://" + host + ":" + port + "/" + name.toLowerCase();
    }

    public static void main(String[] args) {
        ConfigService svc = new ConfigService();
        System.out.println(svc.getConnectionString());
    }
}

Map.get() returns null when the key is absent, something that trips up even experienced Java developers. The code calls name.toLowerCase() without checking, throwing NullPointerException. In a web application, this exception propagates to the error handler, which may return the full stack trace to the client, revealing the class name ConfigService, the method getConnectionString, and the line number. That kind of leaked information is useful for understanding an application’s structure and targeting further attacks.

The Hard-to-Spot Version

These are the ones that are genuinely difficult to catch. The null dereference is buried deep enough that it slips past code review, and it only shows up in production under just the right conditions.

C: Null Propagation Through Multiple Function Calls

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
    char *name;
    int priority;
} Task;

typedef struct {
    Task **tasks;
    int count;
    int capacity;
} TaskQueue;

TaskQueue* queue_create(int capacity) {
    TaskQueue *q = malloc(sizeof(TaskQueue));
    if (!q) return NULL;
    q->tasks = malloc(sizeof(Task*) * capacity);
    if (!q->tasks) {
        free(q);
        return NULL;
    }
    q->count = 0;
    q->capacity = capacity;
    return q;
}

Task* queue_find_by_name(TaskQueue *q, const char *name) {
    for (int i = 0; i < q->count; i++) {
        if (strcmp(q->tasks[i]->name, name) == 0) {
            return q->tasks[i];
        }
    }
    return NULL;  // Not found
}

Task* queue_find_highest_priority(TaskQueue *q) {
    if (q->count == 0) return NULL;
    Task *best = q->tasks[0];
    for (int i = 1; i < q->count; i++) {
        if (q->tasks[i]->priority > best->priority) {
            best = q->tasks[i];
        }
    }
    return best;
}

void process_related_task(TaskQueue *q, const char *related_name) {
    Task *related = queue_find_by_name(q, related_name);
    // No null check, assumes the related task exists
    printf("Processing related task: %s (priority %d)\n",
           related->name, related->priority);
}

void process_next(TaskQueue *q) {
    Task *next = queue_find_highest_priority(q);
    if (!next) {
        printf("Queue empty\n");
        return;
    }
    printf("Processing: %s\n", next->name);
    // The related task name is derived from the current task
    char related_name[128];
    snprintf(related_name, sizeof(related_name), "%s_cleanup", next->name);
    process_related_task(q, related_name);
}

What I find particularly insidious about this one is that the null dereference is two function calls away from the lookup. process_next correctly checks the return of queue_find_highest_priority, but then calls process_related_task, which calls queue_find_by_name and dereferences the result without checking. The null propagates through the call chain: queue_find_by_name returns NULL, process_related_task dereferences it. In review, the developer sees that process_next checks for null and assumes the pattern continues. I’ve caught myself missing this exact pattern, the bug hides because the null check is in a different function than the dereference.

C++: Null Hidden Behind Optional and Smart Pointer Interactions

#include <iostream>
#include <memory>
#include <unordered_map>
#include <string>
#include <optional>

class DatabaseConnection {
public:
    std::string host;
    int port;
    bool connected = false;

    DatabaseConnection(const std::string& h, int p) : host(h), port(p) {}

    bool connect() {
        connected = true;
        std::cout << "Connected to " << host << ":" << port << std::endl;
        return true;
    }

    std::string query(const std::string& sql) {
        if (!connected) return "";
        return "result_for_" + sql;
    }
};

class ConnectionPool {
    std::unordered_map<std::string, std::shared_ptr<DatabaseConnection>> pools_;

public:
    void addConnection(const std::string& name, const std::string& host, int port) {
        pools_[name] = std::make_shared<DatabaseConnection>(host, port);
    }

    std::shared_ptr<DatabaseConnection> getConnection(const std::string& name) {
        auto it = pools_.find(name);
        if (it != pools_.end()) {
            return it->second;
        }
        return nullptr;  // Name not found
    }
};

class UserRepository {
    ConnectionPool& pool_;
    std::string connectionName_;

public:
    UserRepository(ConnectionPool& pool, const std::string& connName)
        : pool_(pool), connectionName_(connName) {}

    std::optional<std::string> findUser(const std::string& userId) {
        auto conn = pool_.getConnection(connectionName_);
        // No null check on conn, assumes the connection name is always valid
        conn->connect();
        std::string result = conn->query("SELECT * FROM users WHERE id = '" + userId + "'");
        if (result.empty()) {
            return std::nullopt;
        }
        return result;
    }
};

int main() {
    ConnectionPool pool;
    pool.addConnection("primary", "db.internal", 5432);

    // Typo in connection name, "primry" instead of "primary"
    UserRepository repo(pool, "primry");
    auto user = repo.findUser("admin");
    if (user) {
        std::cout << "Found: " << *user << std::endl;
    }
    return 0;
}

What I find compelling about this example is how it layers several things that make the bug hard to spot. ConnectionPool::getConnection returns a shared_ptr that may be null. UserRepository::findUser stores the result in auto conn, which hides the fact that it’s a shared_ptr that could be null. The conn->connect() call dereferences the null shared_ptr, which is undefined behaviour in C++. The use of auto obscures the type, shared_ptr gives a false sense of safety (it manages lifetime, not nullability), and the bug only triggers when the connection name is wrong, a configuration error that may not appear until production. This class of bug, a config typo bringing down a service, shows up in real-world incident reports.

Go: Nil Interface vs Nil Pointer Distinction

package main

import (
	"fmt"
	"log"
)

type Validator interface {
	Validate(input string) error
}

type EmailValidator struct {
	maxLength int
}

func (v *EmailValidator) Validate(input string) error {
	if len(input) > v.maxLength {
		return fmt.Errorf("email too long: %d > %d", len(input), v.maxLength)
	}
	if len(input) == 0 {
		return fmt.Errorf("email cannot be empty")
	}
	return nil
}

func NewEmailValidator(maxLen int) *EmailValidator {
	if maxLen <= 0 {
		return nil // Invalid config, return nil
	}
	return &EmailValidator{maxLength: maxLen}
}

func getValidator(validatorType string) Validator {
	switch validatorType {
	case "email":
		v := NewEmailValidator(256)
		return v
	case "strict-email":
		v := NewEmailValidator(-1) // Bug: negative maxLength
		// v is nil (*EmailValidator), but the return type is Validator (interface)
		// This returns a non-nil interface containing a nil pointer!
		return v
	default:
		return nil
	}
}

func processInput(validatorType string, input string) error {
	v := getValidator(validatorType)
	if v == nil {
		return fmt.Errorf("unknown validator type: %s", validatorType)
	}
	// v is non-nil (it's an interface with a type), but the underlying pointer is nil
	// This call panics: nil pointer dereference inside Validate
	return v.Validate(input)
}

func main() {
	err := processInput("strict-email", "user@example.com")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("Validation passed")
}

This is Go’s most notorious nil trap, and I’d argue it’s one of the language’s biggest footguns. NewEmailValidator(-1) returns a nil *EmailValidator. When this nil pointer is returned as a Validator interface, Go wraps it in an interface value that has a non-nil type (*EmailValidator) but a nil data pointer. The v == nil check in processInput passes because the interface itself is not nil, it has a type. But when v.Validate(input) is called, the method receiver is nil, and accessing v.maxLength panics with a nil pointer dereference.

The more I researched this pattern, the more examples I found in production Go code. The nil check looks correct, the code compiles without warnings, and the panic only occurs at runtime when the specific code path is hit. If you write Go, this is a pattern worth burning into your memory, it comes up more often than you’d expect.

Java: Null Propagation Through Method Chains and Conditional Paths

import java.util.List;
import java.util.ArrayList;
import java.util.Optional;

public class OrderProcessor {
    static class Address {
        String street;
        String city;
        String zipCode;

        Address(String street, String city, String zipCode) {
            this.street = street;
            this.city = city;
            this.zipCode = zipCode;
        }
    }

    static class Customer {
        String name;
        Address shippingAddress;
        Address billingAddress;

        Customer(String name, Address shipping, Address billing) {
            this.name = name;
            this.shippingAddress = shipping;
            this.billingAddress = billing;
        }

        Address getPreferredAddress(boolean useBilling) {
            return useBilling ? billingAddress : shippingAddress;
        }
    }

    static class Order {
        Customer customer;
        List<String> items;
        boolean billToShipping;

        Order(Customer customer, List<String> items, boolean billToShipping) {
            this.customer = customer;
            this.items = items;
            this.billToShipping = billToShipping;
        }
    }

    public static String formatShippingLabel(Order order) {
        Customer customer = order.customer;
        // Customer exists, so this looks safe
        Address addr = customer.getPreferredAddress(false);
        // addr may be null if shippingAddress was never set
        String label = customer.name + "\n"
            + addr.street + "\n"
            + addr.city + ", " + addr.zipCode;
        return label;
    }

    public static void processOrders(List<Order> orders) {
        for (Order order : orders) {
            if (order.customer != null && order.items != null && !order.items.isEmpty()) {
                String label = formatShippingLabel(order);
                System.out.println("Shipping label:\n" + label);
            }
        }
    }

    public static void main(String[] args) {
        Customer c = new Customer("Alice", null, new Address("123 Main", "NYC", "10001"));
        List<String> items = new ArrayList<>();
        items.add("Widget");
        Order order = new Order(c, items, false);

        List<Order> orders = new ArrayList<>();
        orders.add(order);
        processOrders(orders);
    }
}

This one is a classic that keeps coming up during Java code reviews. processOrders checks that order.customer != null, but does not check that the customer’s shipping address is non-null. formatShippingLabel calls customer.getPreferredAddress(false), which returns shippingAddress, which is null because Alice was created with null for the shipping address. The addr.street access throws NullPointerException. The null check in processOrders gives a false sense of safety: the customer exists, but the customer’s address does not. This pattern, checking one level of null and assuming everything underneath is safe, is one of the most common sources of NPEs in Java code. It never is safe to assume.

Detection Strategies

Static Analysis

Tool Language What It Catches Limitations
Clang Static Analyzer C/C++ Null dereference after failed allocation, null return values Limited interprocedural tracking
cppcheck C/C++ Unchecked malloc, null dereference in same function Cannot track null across function boundaries
Infer (Facebook) C/C++/Java Null dereference, null propagation across functions May produce false positives on complex control flow
Coverity C/C++/Java Deep interprocedural null dereference analysis Commercial tool
SpotBugs Java NP_NULL_ON_SOME_PATH, unchecked return values Limited to intraprocedural analysis for some checks
NullAway (Uber) Java Null dereference based on @Nullable annotations Requires annotation discipline
go vet / staticcheck Go Some nil dereference patterns Cannot detect nil interface vs nil pointer issues
nilaway (Uber) Go Nil dereference, nil interface issues Newer tool, still maturing

From what I’ve seen, Infer is the best free tool for catching null propagation across function boundaries. For Go specifically, nilaway has shown good results, though it’s still maturing.

Runtime Detection

Tool How It Works Overhead
AddressSanitizer (ASan) Detects null dereference at runtime (and other memory errors) 2x memory, 2x slowdown
Valgrind Tracks all memory access, reports invalid reads/writes at address 0 10-50x slowdown
Go race detector (-race) Detects data races that may cause nil pointer issues 5-10x slowdown
Java -XX:+ShowCodeDetailsInExceptionMessages Enhanced NPE messages showing which expression was null (Java 14+) Negligible

Running ASan in CI for C/C++ projects is worth the overhead, it catches null derefs that would otherwise become production incidents.

Manual Review Indicators

When reviewing code for null dereference issues, these are the patterns worth focusing on:

  1. Unchecked return values from functions that can return null/nil, malloc, Map.get(), database lookups, find operations. This is the single most common source.
  2. auto or var hiding nullable types, when the type is not visible, reviewers miss the null possibility. I’ve been caught by this in C++ more than once.
  3. Null checks on the wrong level, checking that an object is non-null but not checking its fields or the return values of its methods.
  4. Go functions returning concrete types that may be nil, a nil *T returned as an interface I creates a non-nil interface with a nil pointer. This is the one to always flag in Go reviews.
  5. Method chains on potentially null objects, a.getB().getC().doSomething() where any intermediate call may return null. I think of these as “null chain bombs.”
  6. Error paths that skip initialization, a variable declared but only assigned in a try block or an if branch, leaving it null on the error/else path.

Remediation

C: Always Check Allocation Returns

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
    char username[64];
    char role[32];
    int permissions;
} UserSession;

UserSession* create_session(const char *username, const char *role) {
    UserSession *session = malloc(sizeof(UserSession));
    if (!session) {
        fprintf(stderr, "Failed to allocate session\n");
        return NULL;
    }
    strncpy(session->username, username, sizeof(session->username) - 1);
    session->username[sizeof(session->username) - 1] = '\0';
    strncpy(session->role, role, sizeof(session->role) - 1);
    session->role[sizeof(session->role) - 1] = '\0';
    session->permissions = 0;
    return session;
}

Check every malloc, calloc, and realloc return value. Propagate the null to the caller or handle the error immediately. In safety-critical code, using a wrapper that aborts on allocation failure rather than returning null is worth considering, it’s a simpler contract for the rest of the codebase to work with.

C: Null Check at Every Level of the Call Chain

void process_related_task(TaskQueue *q, const char *related_name) {
    Task *related = queue_find_by_name(q, related_name);
    if (!related) {
        printf("Related task '%s' not found, skipping\n", related_name);
        return;
    }
    printf("Processing related task: %s (priority %d)\n",
           related->name, related->priority);
}

Every function that receives a pointer from a lookup must check for null before dereferencing. Don’t rely on the caller to have checked, defensive programming at each layer is the only reliable way to prevent null propagation bugs. Trust no one, especially not the function that called you.

C++: Use Optional and References Instead of Nullable Pointers

#include <optional>
#include <memory>
#include <string>

class ConnectionPool {
    std::unordered_map<std::string, std::shared_ptr<DatabaseConnection>> pools_;

public:
    std::optional<std::shared_ptr<DatabaseConnection>> getConnection(const std::string& name) {
        auto it = pools_.find(name);
        if (it != pools_.end()) {
            return it->second;
        }
        return std::nullopt;  // Explicitly signals absence
    }
};

class UserRepository {
    ConnectionPool& pool_;
    std::string connectionName_;

public:
    UserRepository(ConnectionPool& pool, const std::string& connName)
        : pool_(pool), connectionName_(connName) {}

    std::optional<std::string> findUser(const std::string& userId) {
        auto connOpt = pool_.getConnection(connectionName_);
        if (!connOpt) {
            return std::nullopt;  // Connection not found
        }
        auto& conn = *connOpt;
        conn->connect();
        std::string result = conn->query("SELECT * FROM users WHERE id = '" + userId + "'");
        if (result.empty()) {
            return std::nullopt;
        }
        return result;
    }
};

Return std::optional instead of a nullable pointer. The caller must explicitly unwrap the optional, making the null case visible in the code. This doesn’t prevent all null dereferences, but it moves the null check from a runtime discipline to a type-level signal, and that shift alone catches a huge number of bugs before they ship.

Go: Return the Interface Zero Value, Not a Typed Nil

func NewEmailValidator(maxLen int) *EmailValidator {
	if maxLen <= 0 {
		return nil
	}
	return &EmailValidator{maxLength: maxLen}
}

func getValidator(validatorType string) Validator {
	switch validatorType {
	case "email":
		v := NewEmailValidator(256)
		if v == nil {
			return nil // Return untyped nil, the interface itself is nil
		}
		return v
	case "strict-email":
		v := NewEmailValidator(-1)
		if v == nil {
			return nil // Return untyped nil, not the typed nil pointer
		}
		return v
	default:
		return nil
	}
}

Check the concrete pointer for nil before wrapping it in an interface. Return the bare nil (untyped) so the caller’s if v == nil check works as expected. Alternatively, and this is what I’d actually recommend for production code, return an error alongside the interface to signal failure explicitly:

func getValidator(validatorType string) (Validator, error) {
	switch validatorType {
	case "email":
		v := NewEmailValidator(256)
		if v == nil {
			return nil, fmt.Errorf("failed to create email validator")
		}
		return v, nil
	case "strict-email":
		v := NewEmailValidator(-1)
		if v == nil {
			return nil, fmt.Errorf("invalid config for strict-email validator")
		}
		return v, nil
	default:
		return nil, fmt.Errorf("unknown validator type: %s", validatorType)
	}
}

Java: Use Optional and Null-Safe Patterns

import java.util.Optional;

public class OrderProcessor {
    public static Optional<String> formatShippingLabel(Order order) {
        Customer customer = order.customer;
        Address addr = customer.getPreferredAddress(false);
        if (addr == null) {
            return Optional.empty();
        }
        String label = customer.name + "\n"
            + addr.street + "\n"
            + addr.city + ", " + addr.zipCode;
        return Optional.of(label);
    }

    public static void processOrders(List<Order> orders) {
        for (Order order : orders) {
            if (order.customer != null && order.items != null && !order.items.isEmpty()) {
                formatShippingLabel(order).ifPresentOrElse(
                    label -> System.out.println("Shipping label:\n" + label),
                    () -> System.out.println("No shipping address for " + order.customer.name)
                );
            }
        }
    }
}

Use Optional for return types that may be absent. This forces the caller to handle the empty case. For existing codebases, adding @Nullable annotations and enabling NullAway or Checker Framework provides compile-time null safety without rewriting the entire codebase. Rolling this out on a large Java project can catch dozens of latent null derefs in the first week.

Key Takeaways

Here’s what the research and hands-on experience consistently point to:

  1. Every function that can return null/nil must have its return value checked before dereference. This applies to malloc in C, Map.get() in Java, map lookups in Go, and find operations everywhere. No exceptions.
  2. Null checks at one level do not protect the next level. Checking that an object is non-null does not mean its fields or method return values are non-null. Validate at every layer.
  3. In Go, never return a typed nil pointer as an interface. Check the concrete value for nil and return the bare nil to avoid the nil-interface-with-nil-pointer trap. This one bites every Go developer eventually.
  4. Use type systems to encode nullability. std::optional in C++, Optional in Java, and @Nullable annotations make the null case visible to both the compiler and the reviewer. Let the type system do the work.
  5. Enable static analysis tools that track null flow. Infer, NullAway, nilaway, and Coverity can find null dereferences that span multiple functions, the bugs that manual review misses. Running these in CI is one of the highest-value investments you can make.
  6. In Java 14+, enable -XX:+ShowCodeDetailsInExceptionMessages to get enhanced NPE messages that identify exactly which expression was null. This dramatically reduces debugging time on Java projects.