Non-Control-Flow Attacks
Everything so far (overflows, ROP, bad casts) ultimately hijacked control flow. But an attacker can win without ever changing where the program jumps, by corrupting (or merely reading) ordinary data.
Data-oriented attacks
Ask a sharper question: do you actually need to divert control flow to win? Often the answer is no. A data-oriented attack corrupts ordinary non-control data and leaves the control flow completely intact.
The state of the art is to corrupt security-critical data, leave control flow unchanged, and still do significant damage. A classic example is the Internet Explorer SafeMode bypass {{ cite.yu_blackhat }}. In IE, JScript runs in a locked-down mode called SafeMode, the guard that forbids web-page scripts from reaching dangerous capabilities such as the WScript.Shell object. Any web page you visit runs untrusted JavaScript in your browser; WScript.Shell would let that page's script run operating-system commands on your machine (e.g. launch calc.exe, or download and run malware), so SafeMode normally blocks it. Whether that guard is up is stored as a single flag, safemode, at a fixed offset inside the JScript object:
// jsobj + 0x188 = the SafeMode flag (JScript 5.8) safemode = *(DWORD *)(jsobj + 0x188); // read the guard flag if ( (safemode & 0xB) == 0 ) { // guard DOWN when the flag is 0 ← the check Turn_on_God_Mode(); // dangerous COM objects now allowed }
The guard is that if on line 3: normally safemode is non-zero, the test fails, and the privileged path stays closed. The attack never touches this code. Instead, using an arbitrary-write primitive it simply zeroes the safemode field in memory. The next time IE's own, unmodified code runs this check the test now passes, the guard is down, and WScript.Shell becomes available to run arbitrary commands: no control-flow hijack, no ROP. Another canonical case: the wu-ftpd seteuid bug, where corrupting a stored user-ID gives root without touching any code pointer.
You may not need to corrupt anything at all
Push the idea further. Some attacks just read memory they shouldn't. Heartbleed (2014) is the poster child {{ cite.openssl_advisory }} {{ cite.heartbleed_site }}.
The setting. OpenSSL is the open-source library that most web servers use to speak TLS/HTTPS, the encryption behind the padlock in your browser. When a client (your browser) and a server hold a TLS connection open, either side can send a heartbeat: a small "are you still there?" message. You send a short piece of data plus a number saying how long that data is; the other end simply echoes the same data back. The bug: The OpenSSL code when parsing the HTTP data trusted the claimed length received over the Internet, instead of measuring the data it actually received. The result is that an unauthorised client can leak secrets from the server, such the SSL cryptographic keys.
The cartoon below is the intuition. Imagine the client sends a 4-letter word but claims it's 500 letters, and the server dutifully copies 500 bytes into its reply: your 4 letters plus 496 bytes of whatever sat next to them in server memory (session cookies, passwords, even the private key). No corruption, no control-flow change; just a missing bounds check on a read.
The real bug: a missing bounds check
Stripped of the cartoon, here is the actual vulnerable function (tls1_process_heartbeat / dtls1_process_heartbeat, OpenSSL 1.0.1). A heartbeat request carries a type, a 16-bit length, and a payload, and the length is taken straight from the attacker:
/* OpenSSL 1.0.1 · ssl/t1_lib.c → tls1_process_heartbeat() */ unsigned char *p = &s->s3->rrec.data[0], *pl; unsigned short hbtype; unsigned int payload; /* length the request claims */ hbtype = *p++; /* 1. read message type */ n2s(p, payload); /* 2. payload = length the CLIENT claims ← attacker-controlled */ pl = p; /* 3. pl → real payload bytes */ buffer = OPENSSL_malloc(1 + 2 + payload + padding); bp = buffer; *bp++ = TLS1_HB_RESPONSE; s2n(payload, bp); memcpy(bp, pl, payload); /* BUG: copies 'payload' bytes, but never checks the request actually held that many */
Nothing compares payload against rrec.length, the number of bytes actually received. So memcpy reads up to 65,535 bytes starting at pl, walking off the end of the real request into adjacent heap memory (private keys, session cookies, passwords) and ships it back to the attacker in the response. A buffer over-read, no write required.
The fix (OpenSSL 1.0.1g) is a single bounds check: reject any request whose claimed length exceeds the bytes actually received.
hbtype = *p++; n2s(p, payload); if (1 + 2 + payload + 16 > s->s3->rrec.length) return 0; /* claimed length > bytes received → discard */ pl = p;
Coming soon
Two deeper topics will join this part once the slides are ready:
Chaining non-control-data corruptions into a Turing-complete computation {{ cite.dop }}, the data-only analogue of ROP.
A deeper look at bad-casting beyond the basics: real-world exploits and how tools detect them.
Non-control-flow attacks, in brief
- You can win without hijacking control flow: corrupt non-control data (a flag, an ID, a length) and the program's own code does the damage {{ cite.chen_noncontrol }}.
- Sometimes you needn't corrupt anything at all: a missing bounds check on a read (Heartbleed) leaks secrets outright.
- Because control flow never changes, control-flow defences never fire.
- Still to come: Data-Oriented Programming (DOP) and a deeper look at bad-casting.
{{ course.notes }} · Instructors: {{ course.instructors }}
References
Links last checked July 2026.