8 min read

Buffer Overflow Isn't as Scary as You Think: From "an Overflowing Cup" to Getting a Shell

Buffer Overflow Isn't as Scary as You Think: From "an Overflowing Cup" to Getting a Shell

This one is a bonus spin-off from my last post, the MBPTL walkthrough. In that write-up the final stage (Phase 7, the 31337 binary) ended with me just showing the result — "send a bunch of A's plus three addresses and you get a shell" — without ever explaining why. So this post is dedicated to taking that string of what looks like black magic and pulling it apart in the plainest terms possible. No pwn background required. By the end you'll realize it's really not as scary as it looks.


One sentence first: this bug is really just "writing too much, until it overflows"

You've definitely hit a web form that says "20 characters max." That limit isn't there to annoy you — it's because the program has set aside a chunk of space that fits exactly 20 slots to hold your text. If the program forgets to check the length and you cram 200 characters in there, what happens?

The answer: the extra characters don't just vanish into thin air. They overflow into the memory next door and overwrite whatever was already sitting there. That's a buffer overflow — the name sounds hardcore, but the concept is just "fill the cup too full and the water spills onto the table."

The catch is: the water that spills over happens to flood something very important.

Diagram 1: normal input vs. cramming it full

First, let's see how the program lays things out in memory. It sets aside a row of slots (called the buffer) to hold your input, and directly "above" that row of slots is hidden something called the "return address" — a note recording where the program should go next once this chunk of work is done.

Buffer overflow memory diagramNormal input only fills the buffer; oversized input floods past the buffer and clobbers the program's return address. Normal input Oversized input (crammed full) return address saved RBP buffer (holds your input)128 empty slots Type "Kevin" → only a few slots filled, safe return address ← clobbered! AAAA… (flooded over) AAAA…AAAAall 128 slots stuffed Type 140 A's → overflows and clobbers what's above floods upward Plain-language analogy The buffer is like a row of 128 mailboxes, and the top one is labeled "where to go once you're done." You're only supposed to write into the mailbox slots, but nobody's stopping you — you keep writing until it overflows, and overwrite the "where to go" slot with an address of your choosing. Job done, the program follows the address you wrote.

On the left is the normal case: you type "Kevin," fill only a few slots, and the "return address" above sits safe and sound. On the right is the attack: you type 140 A's, fill all 128 slots and still have leftovers, and those extra A's flood all the way up and clobber the "return address" slot too.

(One line of technical detail: this lab's program uses an old function called gets() to read input — notoriously dangerous because it doesn't check the length at all. However long you feed it, it takes it all. Modern code should never touch it, but it's perfect as a teaching example.)

Diagram 2: clobbering the "return address" is like grabbing the steering wheel

Just overwriting one slot of data — that doesn't sound like a big deal, right? The key is that this "return address" slot isn't ordinary data, it's the program's next instruction.

Every time the program finishes a chunk of work, it looks back at this slot and jumps to whatever address is written there to keep executing. Since we just overwrote it with an address of our own choosing, the program will obediently jump wherever we tell it to.

Hijacking the return addressAfter finishing, the program jumps to whatever the return-address slot says; clobbering it means dictating where the program goes next. Job done, the program checks this slot return addresswe overwrote it with an address AAAA… (padding) buffer stuffed with A's jumps to the place we choseprogram jumps here and runs The turning point Every time it finishes a chunk, the program looks back at the "return address" slot and jumps to whatever address is written there. Since we just overwrote that slot with an address of our own choosing — the program jumps to the place we picked. Going from "overwrite one slot" to "control the program's next step" — that's the moment you seize control.

This step is the soul of the whole attack: upgrading from "I can overwrite one slot of memory" to "I can decide where the program jumps next." The steering wheel is yours.

Diagram 3: jump to where, exactly? Build a shell out of spare parts

The steering wheel is yours — but a new problem shows up: jump to where to actually seize control? What we want is a shell (a command line where we can run commands), but the program doesn't contain a ready-made chunk that says "please give me a shell."

The good news: the program has parts. This lab's binary happens to have two things lying around inside it — a function that can "run a command" (called system), and a piece of text that spells out /bin/sh (the name of the shell itself). What we need to do is assemble these two parts into "run /bin/sh." This technique is called ret2libc.

Assembly takes three steps. There's a rule you need to know first: on this kind of computer, to tell a function to "handle something," you first have to put that something's address into a register called rdi (think of it as the "function's inbox"). So the order is: first put /bin/sh in the inbox, then tell system to get to work.

ret2libc in three stepsUsing the program's existing parts, assemble system opening /bin/sh in three steps. Build "run /bin/sh" out of spare parts ① jump to a gadgetborrow a scrap of existing code ② put it in the inboxinbox = "/bin/sh" ③ tell system to workopens a shell These three steps are really three addresses stacked on the stack ③ address of system ② address of "/bin/sh" ① address of the gadget 136 A's (padding) The padding fills the hole up to just the right spot, then the three addresses are chained "return → gadget → system" in order, and the program, led by the nose, walks itself step by step into "run /bin/sh."

What is that "① gadget"? It's a tiny fragment we dig out of the program's existing machine code, one that happens to do exactly "put a value into the inbox, then continue." We can't conjure code out of nowhere and write it in (I'll explain why later), but we can borrow the parts the program is already made of — that's the clever bit of this technique. At no point do we smuggle in a single line of our own code; we just rearrange someone else's building blocks.

And the bottom half of the diagram reveals what that mysterious payload really is: it's just "a pile of padding + three addresses" stacked in memory in order. The padding fills the hole up to exactly where it butts against the "return address," and then the three addresses, one after another, lead the program like a marionette, step by step, into system("/bin/sh").

Back to that "magic string you couldn't read" from the last post — line it up now and it's clear:

payload = b'A' * 136        # padding: fill the hole, butting right up against the return address
        + p64(gadget)       # ① clobber the return address → jump to the gadget
        + p64(binsh)        # ② the value the gadget drops into the inbox = "/bin/sh"
        + p64(system)       # ③ finally jump to system, get to work

That "136" isn't a random number, it's precisely calculated — the buffer is 128 slots, plus 8 more in between, so exactly after 136 slots comes the "return address." One slot too many or too few and it fails.

So why don't most modern programs get owned like this? Three locks

By now you might be thinking: this is so easy, isn't every program in the world doomed? Actually no — modern compilers long ago fitted three layers of defense that make every step above brutally hard. The reason this lab sails straight through is that it deliberately turns all three locks off for teaching purposes.

Three defense mechanismsModern programs have three locks against buffer overflow: the canary, address randomization, and a non-executable stack. A modern program's three locks (all off this round) The canaryStack Canary A random codeword sitsbefore the return address;touch it and it aborts Address randomizationPIE / ASLR Addresses shift every run,so you can't hardcodethe parts' addresses Non-executable stackNX Can't stuff malicious codein and run it directly(so we "borrow" instead) Turn on any one lock and the difficulty jumps a tier — this level just happens to have all three off, which is why it's beginner material.
  • The canary (Stack Canary): it quietly plants a random codeword right in front of the "return address." Before the program jumps back, it first checks whether the codeword is still intact — when you overflow, you're guaranteed to overwrite it, and the moment the codeword doesn't match, the program self-destructs on the spot, before you ever get a chance to hijack anything. The name comes from miners taking a canary down the mine: the bird dies first, and people know there's toxic gas.
  • Address randomization (PIE / ASLR): every time the program runs, the parts get shuffled to random locations. Those three "addresses" in the payload above? You simply can't hardcode them — this run it's at one spot, next run it's somewhere else. (If you've ever played with ARM exploitation, this one won't be new to you; bypassing ASLR is bread and butter for the advanced challenges.)
  • Non-executable stack (NX): early attacks just stuffed malicious code straight into the buffer and ran it. The NX lock decrees "this chunk of memory can only hold data, not run as code." This is exactly why we don't stuff in our own code, but instead go "borrow" the program's existing parts — the ret2libc technique was born precisely to get around NX.

Turn on any single one of these three locks and the difficulty jumps a whole tier. Real-world programs usually have all three on, which is why modern pwn experts spend most of their effort on "how to peel off these locks one by one" — and that's a much, much deeper rabbit hole.

Wrapping up

Stringing the whole thing together in one line:

The program forgot to check the length → I cram the buffer full, flood over, and clobber the "return address" → so now I can decide where the program jumps next → and using the program's own parts, I lead it step by step into a shell.

Look back at that b'A'*136 + three addresses from the last post, and it shouldn't be black magic anymore — it's just "fill the padding to exactly the right point, then use three addresses to lead the program by the nose."

If you're like me and used to skip right past anything labeled pwn, I hope this post left you feeling: it's tough, sure, but the skeleton is actually pretty easy to grasp. The genuinely hard part isn't this intro version — it's how you get around all three locks once they're on. But that one, let's save for the day you actually decide to jump into this rabbit hole.