Injections: when data becomes a command
Problem
A thumbnail with a surprise
Buyers upload photos with their reviews. The shop’s tool makes a thumbnail of each one: it runs the image program (for example magick from ImageMagick) with the file’s name.
Here echo stands in for the real converter. It only prints its arguments, so the output shows exactly what reached the program:
import { execSync } from "node:child_process";
function makeThumbnail(fileName) { const command = "echo resize " + fileName; const out = execSync(command); process.stdout.write(out);}
makeThumbnail("shoes.jpg");makeThumbnail("shoes.jpg; echo injected");resize shoes.jpgresize shoes.jpginjectedThe first name is ordinary. The second is shoes.jpg; echo injected, and the output has three lines, not two. injected was printed by a command the buyer wrote into the file’s name. Here it is a harmless echo, but any command could stand in its place, and it runs with the rights of the shop’s server.
The idea
Text that ends up in a language
A command for the shell is a language. In it ; means “end of command, another one follows”. The code glued the buyer’s name into that language, and the characters in the name changed the meaning of the command. This is command injection.
You have seen the same with SQL. When a program glues ' OR '1'='1 into a query, the quote closes the text early and the condition becomes true for every row. The fix was a parameter: the query and the value go to the database separately. That is the lesson “Databases” in the language course and “SQL injection and parameters” in the SQL course, so we will not repeat it here.
Wherever text ends up in a language, some characters mean something:
| Where the text ends up | Characters with meaning | How it stays data |
|---|---|---|
| an SQL query | ', --, ; |
a parameter |
| a command for the shell | ;, &&, $( ), a space |
an argument list, no shell |
| a file path | .., /, \, a path from the root |
a canonical path and a check, or a number from a list |
| an HTML page | <, >, ", & |
escaping (the next lesson) |
The common idea: do not let data become part of the command. Hand it to the system separately, in a way the system never reads as a command.
See it
How the name becomes a command
Both cases from the lesson, before and after the fix. The code next to the scene changes with the step:
function makeThumbnail(fileName) { const command = "echo resize " + fileName; const out = execSync(command); process.stdout.write(out);}function makeThumbnail(fileName) { const command = "echo resize " + fileName; const out = execSync(command); process.stdout.write(out);}function makeThumbnail(fileName) { const out = execFileSync("echo", ["resize", fileName]); process.stdout.write(out);}function download(name) { const file = path.join(invoices, name); return fs.readFileSync(file, "utf8");}function download(name) { const file = path.resolve(invoices, name); if (!file.startsWith(invoices + path.sep)) { return "Refused: outside the invoice folder"; } return fs.readFileSync(file, "utf8");}Step by step, in words
- A buyer uploads a photo named “shoes.jpg; echo injected”. The shop’s tool makes a thumbnail from it.
- The code glues the name into one string for the shell. The name is no longer data: it is part of a command.
- To the shell, “;” ends a command. The string becomes two commands, and the buyer wrote the second. Here it only prints “injected”; it could be any command.
- The fix: no shell. The program gets a list of arguments, and the whole name is one argument. Here “;” is an ordinary character in it.
- The invoice download joins the name from the address to the invoices/ folder. The name is “../settings.txt”, and “..” means “one folder up”.
- The path climbs out of invoices/ and the file read is settings.txt, next to it. The name chose a different file.
- The fix: first work out where the path really ends (the canonical path), then check it is still inside invoices/. settings.txt is not: refused.
Try it
Find the line
The shop sends a receipt: it makes a PDF and sends it to the buyer’s email. Which line hands text from the buyer to the shell?
Pick the line with the bug.
The last one. The email is text from the buyer, and execSync hands it to the shell as part of the command. The other two lines pass an argument list, and pdf is built from the order number, not from the buyer’s text.
The idea
An argument list instead of a string
The fix is not to “clean” the name. It is to have no shell that reads it. The program is started with an argument list: the first item is the program, and every item after it reaches the program whole, whatever characters it contains.
import { execFileSync } from "node:child_process";
function makeThumbnail(fileName) { const out = execFileSync("echo", ["resize", fileName]); process.stdout.write(out);}
makeThumbnail("shoes.jpg");makeThumbnail("shoes.jpg; echo injected");resize shoes.jpgresize shoes.jpg; echo injectedThe second name reached the converter as one argument, with the ; inside. There is no second command.
execFileSync("echo", [...]) starts the program directly, with no shell, while execSync(string) always goes through the shell. The same holds for execFile versus exec, and for spawn without shell: true.More precisely, if you are curious
- Best of all, do not start an external program at all. There are libraries for images: sharp. Then there is no command for text to end up in.
- Keep your own names. Save the uploaded file under a name the program makes up (a number or a UUID), and keep the buyer’s name as data in the database. Then only your name reaches the command and the path.
- An argument that starts with
-. Without a shell;is harmless, but the program may still read a name like-xas one of its options. That is why many programs accept--(“end of options”) before the file names, or you put./in front of the name. -
child_process.execandexecSyncstart a shell, whilespawn,execFileandexecFileSyncdo not, unless you give themshell: true.
Problem
An invoice that is not an invoice
A buyer downloads an invoice from /invoices/1041.txt. The server takes the name from the address and joins it to the invoice folder:
function download(name) { const file = path.join(invoices, name); return fs.readFileSync(file, "utf8");}
console.log(download("1041.txt"));console.log(download("../settings.txt"));Invoice 1041: 59 €payment_api_key=demo-not-realThe second name is ../settings.txt. In a path .. means “one folder up”. The path left invoices/, and the server read settings.txt, which sits next to it, with the payment key.
This is path traversal: the name in the request picks a file outside the folder the code had in mind. The program here makes its own temporary folder and reads only inside it; on a real server .. can climb many levels up.
The idea
A canonical path, then a check
The fix has two steps:
- Work out where the path really ends. That is the canonical path: complete, with no
.and... - Check that the canonical path is still inside the invoice folder. If it is not, refuse.
function download(name) { const file = path.resolve(invoices, name); if (!file.startsWith(invoices + path.sep)) { return "Refused: outside the invoice folder"; } return fs.readFileSync(file, "utf8");}
console.log(download("1041.txt"));console.log(download("../settings.txt"));Invoice 1041: 59 €Refused: outside the invoice folderpath.resolve(invoices, name) works out the full path and removes ... The check uses invoices + path.sep: without the separator at the end, a folder invoices-old would pass too.The check does not look for dangerous characters. It asks where the path ends, so it does not depend on what you thought of.
Safer still is for the name not to be a path at all. The address carries an invoice number, and the server looks the file up in its own table: a number that is not there is refused. That is the exercise below.
More precisely, if you are curious
- Symbolic links.
path.resolveworks only on the text of the path. If the folder may contain a symbolic link pointing outside, usefs.realpathSync. - A path from the root.
path.resolve(invoices, "/etc/hosts")is/etc/hosts: the absolute path replaces the folder. The check catches it. - Archives. The file names inside a zip archive are text from outside too. Unpacking must do the same check for every file (the trap has a name: zip slip).
- The setup. The program makes a temporary folder, writes the two invoices and the settings file into it, and deletes it at the end. Here is how it starts:
const shop = fs.mkdtempSync(path.join(os.tmpdir(), "shop-"));const invoices = path.join(shop, "invoices");fs.mkdirSync(invoices);fs.writeFileSync(path.join(invoices, "1041.txt"), "Invoice 1041: 59 €");fs.writeFileSync(path.join(invoices, "1042.txt"), "Invoice 1042: 118 €");fs.writeFileSync(path.join(shop, "settings.txt"), "payment_api_key=demo-not-real");Try it
The canonical path
Finish the line that works out where the path ends:
function download(name) {
const file = path.(invoices, name);
if (!file.startsWith(invoices + path.sep)) {
return "Refused: outside the invoice folder";
}
return fs.readFileSync(file, "utf8");
}path.resolve(invoices, name) works out the full path without ... The check on the next line looks at that, not at the text from the address.
Try it
Three names
What does the fixed download print for these three names?
console.log(download("1042.txt"));console.log(download("../invoices/1042.txt"));console.log(download("../settings.txt"));1042.txt is in the folder. ../invoices/1042.txt leaves invoices/ and comes back into it: the canonical path is invoices/1042.txt, so it passes. ../settings.txt ends outside the folder: refused.
Exercise
The invoice by number
Solve it in your own editor.
By number, not by name. Write downloadById(id). The address carries only an invoice number. A table in the code says which number is which file (1041 → 1041.txt, 1042 → 1042.txt). A number that is not in the table is refused, and the file name never comes from the request. Try it with 1041, 1042, ../settings.txt and 1043:
Invoice 1041: 59 €Invoice 1042: 118 €Refused: no such invoiceRefused: no such invoiceMore tasks
- Someone else’s invoice. The number
1042is in the table, but the invoice belongs to another buyer. What else must the server check before returning it? - The photo’s name. Change the thumbnail tool so that it saves the uploaded file under a name the program makes up (the review’s number and
.jpg), and keeps the buyer’s name only as text.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Arrays: push, splice and sort · Map and Set · Databases · Untrusted input
- Continues in: XSS: someone else’s script in your page
- Tasks on this topic: Security for developers