Browse CTFs New CTF Sign in

Integer Truncation Exploitation: 64-to-32-Bit Narrowing, Size Check Bypass and Memory Corruption

binary_exploitation Difficulty 1–5 30 min certifiable

Theory

Why This Matters

Integer truncation — assigning a wider type into a narrower variable — silently discards the upper bits and produces a value the programmer did not intend. CVE-2018-6789 (Exim integer truncation to heap overflow) allowed pre-auth remote code execution affecting millions of mail servers. NICE K0168 and T0028 both require recognising how type mismatches in size computations create exploitable gaps between a check and the subsequent use. On 64-bit systems, the most common pattern is using int or unsigned int for a length derived from a 64-bit strlen or recv, then using the truncated value as a copy or allocation size.

Core Concept

Truncation occurs when a value of type T is assigned to a narrower type U. The high bits that do not fit into U are discarded silently — no exception, no compiler warning at default settings.

size_t big = 0x100000041ULL;   // 64-bit value: 4294967361
int    small = (int)big;       // truncated to 32-bit: 0x41 = 65

The canonical vulnerability pattern:

// user sends a 64-bit length field
uint64_t user_len = read_uint64(sock);

// programmer truncates to int, believing "large values will be negative and caught"
int len = (int)user_len;           // user_len = 0x100000010 -> len = 0x10 = 16

if (len < 0 || len > MAX_BUF) {   // check passes: 16 < MAX_BUF
    return ERROR;
}

char *buf = malloc(len);           // allocates 16 bytes
recv(sock, buf, user_len);         // copies 0x100000010 bytes — OVERFLOW

The check on len passes because truncation made the value small. But recv uses the original 64-bit user_len as the byte count, so it overflows the 16-byte allocation.

int len = strlen(buf) for huge buffers: strlen returns size_t (64-bit on 64-bit platforms). If a buffer is longer than INT_MAX (2147483647 bytes), assigning the result to int wraps to a negative value. A subsequent check if (len > MAX) may still pass if MAX is an int and len is negative — because -1 < MAX is true. Then memcpy(dst, src, len) with len cast to size_t copies SIZE_MAX bytes.

Unsigned size check bypass by wrapping to small:

unsigned int count = user_input;       // e.g. 0xFFFFFFFD
unsigned int total = count + 3;        // wraps: 0xFFFFFFFD + 3 = 0x00000000
if (total > MAX_ITEMS) return;         // 0 < MAX_ITEMS: check passes
char *arr = malloc(total * 4);         // malloc(0) — returns non-NULL small buffer
// subsequent fill of `count` elements overflows

Technical Deep-Dive

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

// Vulnerable pattern: size_t -> int truncation
char *copy_user_data(const char *src) {
    int len = strlen(src);          // strlen returns size_t; truncated to int
    char *dst = malloc(len + 1);   // if len was negative due to truncation, malloc(negative+1) = malloc(0) or wraps
    memcpy(dst, src, len);
    dst[len] = '';
    return dst;
}

// Safe: keep size_t throughout
char *copy_user_data_safe(const char *src) {
    size_t len = strlen(src);
    if (len >= SIZE_MAX - 1) return NULL;
    char *dst = malloc(len + 1);
    if (!dst) return NULL;
    memcpy(dst, src, len);
    dst[len] = '';
    return dst;
}

Demonstrating the unsigned wrap-to-small bypass in Python:

import ctypes

# Simulate 32-bit unsigned arithmetic
count = ctypes.c_uint32(0xFFFFFFFD).value
total = ctypes.c_uint32(count + 3).value
print(f"count = {count:#x}, total = {total:#x}")
# count = 0xfffffffd, total = 0x0
# if total < MAX: check passes; malloc(0 * 4) = malloc(0) -- minimal allocation
# but memcpy uses count (0xFFFFFFFD) elements -- massive overflow

Identifying truncation in Ghidra decompiler output:

// Ghidra will show explicit casts in decompiled C:
iVar1 = (int)strlen(user_buf);     // <- red flag: size_t cast to int
if (iVar1 < 0x100) {               // signed check on truncated size
    malloc((long)iVar1 + 1);       // cast back to long -- original value gone
}

The cast (long)iVar1 after truncation does not recover the original 64-bit value — it sign-extends the 32-bit int, which may be negative if the original was > INT_MAX.

Reverse Engineering Methodology

  1. In Ghidra, enable the "Data Type Propagation" analysis pass. Look for (int) casts applied to results of string or I/O functions (strlen, read, recv, fread) — these are truncation points.
  2. Trace every user-controlled length value through type assignments. Any narrowing assignment (64-bit result into 32-bit variable) is a candidate.
  3. Check whether the narrowed value is used in both a conditional check and a copy/allocation. The check uses the truncated value; the copy may use the original.
  4. Search for mismatched types in function call sites: recv(fd, buf, signed_int_var) passes signed_int_var as size_t — if negative, it becomes a very large count.

Common Reversing Errors

  • Trusting the decompiler's type inference: Ghidra and IDA sometimes infer types incorrectly and show clean size_t usage when the assembly is actually doing 32-bit arithmetic. Always cross-check the decompiled output against the actual instruction widths (DWORD PTR vs QWORD PTR).
  • Assuming the check variable and the copy variable are the same: a subtle bug has the check use len (truncated) and the copy use user_len (original). These may be in different stack slots despite looking similar in decompilation.
  • Missing the sign extension on function call: passing a negative int as a size_t argument sign-extends to a huge unsigned value. This is legal C but almost always a bug. Look for function signatures that take size_t called with int locals.
  • Overlooking intermediate arithmetic: len / 2 after truncation may still be positive while the original was huge; the division does not undo the overflow — it just shifts the exploitable range.

Challenge Lab

Reinforce your learning with a hands-on generated challenge based on this card's competency.