Skip to content

CWE-1105: Insufficient Encapsulation of Machine-Dependent Functionality - C

Overview

C gives direct access to pointer representation, raw memory layout, and the underlying instruction set, so machine-dependent assumptions creep in easily: casting a pointer to a fixed-width integer, reinterpreting a byte buffer as a multi-byte value without checking byte order, accessing a multi-byte type through a misaligned pointer, or using an instruction set extension without checking it's actually present. Each of these works fine on the platform the code was written and tested on, then breaks - silently or with a crash - on a different word size, endianness, or CPU.

Common Vulnerable Patterns

Pointer Truncated to a Fixed-Width Integer

// VULNERABLE - truncates the pointer on 64-bit platforms
struct session *s = malloc(sizeof *s);

int handle = (int)s;                 // WRONG: pointer -> int truncates on LP64
store_handle_in_table(handle);
// ...
struct session *back = (struct session *)handle;   // not the address we stored

Why this is vulnerable: On a 64-bit (LP64) platform, int is still 32 bits while a pointer is 64 bits. Casting a pointer down to int silently discards the upper 32 bits, and casting the remainder back produces a different address than the one malloc returned - a dereference into memory that has nothing to do with the object, which is memory corruption rather than a portability nit. The round trip has to start from a real object address for this to bite: an int widened into a void * and cast straight back loses nothing, which is why a handle table that only ever stores small integers can survive this cast for years and fail the moment someone stores a pointer in it.

The cast is also what silences the compiler. Without it, -Wint-conversion refuses the assignment outright; written as a cast it is a request rather than a mistake, and the warning that remains is -Wpointer-to-int-cast, which fires only where the widths differ. That is the flag to turn into an error.

Raw Memory Reinterpreted Without Checking Byte Order

// VULNERABLE - assumes the buffer's byte order matches the host's
uint32_t value = *(uint32_t *)buffer;   // wrong result on the "other" endianness

Why this is vulnerable: A value written by a big-endian sender (or a file format that specifies big-endian, such as most network protocols) is misread on a little-endian host, and vice versa. This isn't a compile error or even a crash - it silently produces a different number, which is far more dangerous when that number is a length, an offset, or a size used in a later memory operation.

Unaligned Access Through a Cast

// VULNERABLE - may fault on architectures that require aligned access
char buffer[100];
int *ptr = (int *)(buffer + 1);   // buffer+1 is not 4-byte aligned
*ptr = 42;                        // SIGBUS on some architectures (e.g. some ARM configurations)

Why this is vulnerable: x86 tolerates unaligned access (with a performance penalty); several other architectures fault on it. Code developed and tested only on x86 can ship with an unaligned-access bug that crashes on ARM or other targets, which is a portability and availability problem more than a memory-safety one, but it's a straightforward denial-of-service if the offset is attacker-influenced.

Secure Patterns

Use a Pointer-Sized Integer Type

// SECURE - never truncates, regardless of platform pointer width
#include <stdint.h>

struct session *s = malloc(sizeof *s);

uintptr_t handle = (uintptr_t)s;      // round-trips correctly on any platform
store_handle_in_table(handle);
// ...
struct session *back = (struct session *)handle;   // the address we stored

Why this works: uintptr_t (and its signed counterpart intptr_t) is guaranteed by the standard to be wide enough to hold a void * on the platform it is compiled for, so the round-trip through an integer never loses bits, on any architecture. This is the same handle table as the vulnerable example above, with the one type changed - which is the whole fix, and the reason to keep the table's type in a typedef rather than spelled int at each use site.

Convert Byte Order Explicitly at Every Boundary

// SECURE - explicit conversion at the point data crosses a boundary
#include <arpa/inet.h>
#include <stdint.h>
#include <string.h>

// buffer is unsigned: see the note below on why the type matters here
const uint8_t *buffer = wire_bytes;

uint32_t raw;
memcpy(&raw, buffer, sizeof raw);              // no alignment or aliasing assumption
uint32_t value = ntohl(raw);                   // network (big-endian) -> host

uint32_t network_value = htonl(value);         // host -> network (big-endian)
memcpy(out, &network_value, sizeof network_value);

// For a format with a documented byte order that isn't network order,
// unpack byte-by-byte instead of relying on a library that assumes network order:
uint32_t manual_value = ((uint32_t)buffer[0] << 24) |
                         ((uint32_t)buffer[1] << 16) |
                         ((uint32_t)buffer[2] << 8)  |
                          (uint32_t)buffer[3];

Why this works: ntohl/htonl (and their 16-bit counterparts ntohs/htons) convert between network byte order and whatever the host's native byte order happens to be, so the same source code produces correct results on both big-endian and little-endian hosts.

The memcpy is not decoration. ntohl(*(uint32_t *)buffer) fixes the byte order and leaves the other two defects in that line untouched: the cast still assumes buffer is four-byte aligned, which faults on the architectures the next section is about, and it still reads a char buffer through a uint32_t lvalue, which is a strict-aliasing violation the optimizer is entitled to act on. memcpy into a local of the right type removes both, and every mainstream compiler turns it back into a single load where the target allows one, so it costs nothing. The manual byte-by-byte form does the same job for any documented byte order, including ones that are not network order, by never reading the bytes as a single multi-byte load in the first place.

The manual form only works on an unsigned buffer, and that is this CWE's own subject biting the fix. Whether plain char is signed is implementation-defined - it is signed on x86 Linux and unsigned on ARM Linux - so with a char * buffer, any byte from 0x80 to 0xFF is a negative int by the time it reaches the cast, and (uint32_t) sign-extends it to 0xFFFFFF80-style before the shift. Working it through for the bytes 80 80 00 01: the unsigned path gives 0x80800001 and the signed path gives 0xFF800001, because the top byte's extra bits shift out harmlessly and the second byte's do not. The high byte therefore looks correct while the ones under it are corrupted, which is the worst version of the bug to debug. Declare the buffer const uint8_t *, or cast each byte through (unsigned char) before widening - do not rely on the platform's char signedness on a page about not relying on the platform.

Access Unaligned Data with memcpy

// SECURE - memcpy handles unaligned source/destination correctly
int value = 42;
memcpy(buffer + 1, &value, sizeof(value));

int read_back;
memcpy(&read_back, buffer + 1, sizeof(read_back));

Why this works: memcpy is specified to work correctly regardless of the alignment of its source and destination pointers - the compiler generates whatever byte-at-a-time or aligned-load sequence the target architecture actually requires, instead of the program assuming a single aligned load will work everywhere.

Detect CPU Features at Runtime with a Correct Fallback

// SECURE - runtime feature check with a genuine software fallback
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

#if defined(__x86_64__) || defined(__i386__)
#include <cpuid.h>          // x86 only - this header does not exist on ARM
#endif

bool has_aes_ni(void) {
#if defined(__x86_64__) || defined(__i386__)
    unsigned int eax, ebx, ecx, edx;
    if (__get_cpuid(1, &eax, &ebx, &ecx, &edx)) {
        return (ecx & bit_AES) != 0;
    }
#endif
    return false;       // unknown architecture: take the software path
}

void encrypt_data(const uint8_t *data, size_t len) {
    if (has_aes_ni()) {
        aes_ni_encrypt(data, len);   // hardware-accelerated path
    } else {
        aes_sw_encrypt(data, len);   // equally correct software fallback
    }
}

Why this works: __get_cpuid queries the actual running CPU instead of assuming a feature is present because it was available on the build or development machine. Both branches implement the same correct encryption - the fallback changes performance, not correctness, so a CPU without AES-NI still gets a secure result rather than a silently degraded one.

The #if around <cpuid.h> is the encapsulation this CWE is named for. <cpuid.h> is a GCC/Clang x86 header and does not exist on ARM, so an unguarded include turns a portability improvement into a build failure on the target it was meant to support - and the guard has to wrap the header as well as the call, which is the half that is usually missed. Everything architecture-specific belongs inside one function like this, so the rest of the program asks has_aes_ni() and never sees a register.

Compile-time guards such as #ifdef __AVX2__ describe the machine that built the binary, not the one running it. GCC and Clang expose __builtin_cpu_supports("avx2") for the runtime question, and MSVC has __cpuid; a build-time macro alone will fault on an older CPU.

Avoid Architecture-Specific Inline Assembly

#include <stddef.h>
#include <stdint.h>

// VULNERABLE - x86-specific inline assembly, undefined on other architectures
void secure_zero_x86(void *ptr, size_t len) {
    asm volatile("rep stosb" : "=D"(ptr), "=c"(len) : "a"(0), "0"(ptr), "1"(len) : "memory");
}

// SECURE - portable, and not eliminated by the optimizer like a plain memset
void secure_zero(void *ptr, size_t len) {
    volatile uint8_t *p = ptr;
    while (len--) {
        *p++ = 0;
    }
}

Why this works: The volatile pointer forces every byte write to actually happen, so the compiler cannot drop the loop as dead code (a real risk with a plain memset right before freeing the buffer), without depending on one architecture's assembly mnemonics. It compiles and behaves identically on x86, ARM, or any other target.

Reach for a maintained implementation before writing this one, though. C23 standardises memset_explicit in <string.h>; glibc 2.25 and the BSDs provide explicit_bzero; Windows has SecureZeroMemory. Each is specified not to be optimised away, and each is tested against its own compiler in a way a hand-rolled loop in your codebase is not. The loop above is the fallback for a target that offers none of them - which is the same encapsulation point as the CPU-feature example: one function selects, and nothing else in the program knows which of the four it got.

Testing

  • Build and run (or emulate, e.g. under QEMU) on both a 32-bit and a 64-bit target, and on both a little-endian and a big-endian target if the code touches serialized data.
  • Force has_aes_ni() (or equivalent) to return false and confirm the software fallback still produces correct output.
  • Fuzz or property-test the byte-order conversion functions with boundary values (0, UINT32_MAX, values with a 0x00 or 0xFF in each byte position).
  • Run with a sanitizer enabled in CI. UndefinedBehaviorSanitizer's alignment check reports the misaligned access at the point it happens, and AddressSanitizer catches the memory-safety consequences downstream.
  • Do not expect a sanitizer to find the pointer truncation: the narrowing is written as an explicit cast, which is exactly the form that tells every checker it was intended. The compiler is the tool that sees it - build with -Wpointer-to-int-cast (on by default under -Wall where the widths differ) and -Wconversion, and treat those two as errors.

Additional Resources