Инжекции: когато данните станат команда
Проблем
Миниатюра с изненада
Купувачите качват снимки към отзивите си. Инструментът на магазина прави от всяка миниатюра: пуска програмата за снимки (например magick от ImageMagick) с името на файла.
Тук вместо истинския конвертор стои echo. Той само отпечатва аргументите си, затова изходът показва точно какво е стигнало до програмата:
using System.Diagnostics;
void MakeThumbnail(string fileName){ string command = "echo resize " + fileName; Run(new ProcessStartInfo("sh", ["-c", command]));}
void Run(ProcessStartInfo info){ info.RedirectStandardOutput = true; using var process = Process.Start(info)!; Console.Write(process.StandardOutput.ReadToEnd()); process.WaitForExit();}
MakeThumbnail("shoes.jpg");MakeThumbnail("shoes.jpg; echo injected");resize shoes.jpgresize shoes.jpginjectedПървото име е обикновено. Второто е shoes.jpg; echo injected, и изходът има три реда, а не два. injected го отпечата команда, която купувачът е написал в името на файла. Тук тя е безобидно echo, но на нейно място може да стои всяка команда, и тя тръгва с правата на сървъра на магазина.
Идеята
Текст, който влиза в език
Командата за shell-а е език. В него ; значи „край на командата, следва друга“. Кодът слепи името на купувача в този език, и знаците в името смениха смисъла на командата. Това е инжекция в командния ред (command injection).
Същото вече си виждал с SQL. Когато програмата слепи ' OR '1'='1 в заявката, кавичката затваря текста по-рано и условието става вярно за всеки ред. Поправката беше параметър: заявката и стойността отиват в базата поотделно. Това е урокът „Бази данни“ в курса по езика и „SQL injection и параметрите“ в курса по SQL, затова тук няма да го повтаряме.
Където текст влиза в език, има знаци със смисъл:
| Къде влиза текстът | Знаци със смисъл | Как остава данни |
|---|---|---|
| SQL заявка | ', --, ; |
параметър |
| команда за shell-а | ;, &&, $( ), интервал |
списък от аргументи, без shell |
| път до файл | .., /, \, път от корена |
каноничен път и проверка, или номер от списък |
| HTML страница | <, >, ", & |
escape (следващият урок) |
Обща идея: не позволявай данните да станат част от командата. Дай ги на системата отделно, по начин, по който тя никога не ги чете като команда.
Виж го
Как името става команда
Двата случая от урока, преди и след поправката. Кодът до сцената се сменя със стъпката:
void MakeThumbnail(string fileName){ string command = "echo resize " + fileName; Run(new ProcessStartInfo("sh", ["-c", command]));}void MakeThumbnail(string fileName){ string command = "echo resize " + fileName; Run(new ProcessStartInfo("sh", ["-c", command]));}void MakeThumbnail(string fileName){ var info = new ProcessStartInfo("echo"); info.ArgumentList.Add("resize"); info.ArgumentList.Add(fileName); Run(info);}string Download(string name){ string file = Path.Combine(invoices, name); return File.ReadAllText(file);}string Download(string name){ string file = Path.GetFullPath(name, invoices); if (!file.StartsWith(invoices, StringComparison.Ordinal)) { return "Отказ: извън папката с фактури"; } return File.ReadAllText(file);}Стъпка по стъпка, с думи
- Купувачът качва снимка с име „shoes.jpg; echo injected“. Инструментът на магазина прави от нея миниатюра.
- Кодът слепва името в един низ за shell-а. Името вече не е данни: то е част от команда.
- За shell-а „;“ е край на командата. Низът става две команди, а втората я е написал купувачът. Тук само печата „injected“, но можеше да е всяка команда.
- Поправката: без shell. Програмата получава списък от аргументи и цялото име е един аргумент. В него „;“ е обикновен знак.
- Изтеглянето на фактура слепва името от адреса към папката invoices/. Името е „../settings.txt“, а „..“ значи „една папка нагоре“.
- Пътят излиза от invoices/ и се чете settings.txt, който е до нея. Името е избрало друг файл.
- Поправката: първо сметни докъде стига пътят наистина (каноничния път), после провери, че е още в invoices/. settings.txt не е: отказ.
Пробвай
Намери реда
Магазинът праща касова бележка: прави PDF и го изпраща на имейла на купувача. Кой ред дава текст от купувача на shell-а?
Избери реда с грешката.
Последният. Имейлът е текст от купувача, а new ProcessStartInfo("sh", ["-c", …]) го дава на shell-а като част от командата. Другите два реда подават списък от аргументи, а pdf е съставен от номера на поръчката, не от текст на купувача.
Идеята
Списък от аргументи вместо низ
Поправката не е да „почистиш“ името. Тя е да няма shell, който да го чете. Програмата се пуска с списък от аргументи: първият е програмата, а всеки следващ стига до нея цял, каквито и знаци да има в него.
using System.Diagnostics;
void MakeThumbnail(string fileName){ var info = new ProcessStartInfo("echo"); info.ArgumentList.Add("resize"); info.ArgumentList.Add(fileName); Run(info);}
void Run(ProcessStartInfo info){ info.RedirectStandardOutput = true; using var process = Process.Start(info)!; Console.Write(process.StandardOutput.ReadToEnd()); process.WaitForExit();}
MakeThumbnail("shoes.jpg");MakeThumbnail("shoes.jpg; echo injected");resize shoes.jpgresize shoes.jpg; echo injectedВторото име стигна до конвертора като един аргумент, с ; вътре. Втора команда няма.
ProcessStartInfo с ArgumentList пуска програмата направо, без shell. .NET подава всеки елемент на списъка като отделен аргумент и сам слага кавичките, които трябват.По-точно, за любопитните
- Най-добре е изобщо да не пускаш външна програма. За снимките има библиотеки: ImageSharp или SkiaSharp. Тогава няма команда, в която да влезе текст.
- Пази имената си. Запиши каченото файлче под име, което програмата измисля (номер или UUID), а името на купувача остави като данни в базата. Тогава в командата и в пътя стига само твоето име.
- Аргумент, който започва с
-. Без shell;е безвреден, но програмата пак може да прочете име като-xкато своя опция. Затова много програми приемат--(„край на опциите“) преди имената на файлове, или слагаш./пред името. -
ProcessStartInfo("sh", "-c \"…\"")с низ вArgumentsе същият проблем: .NET цепи низа на аргументи, аsh -cпосле чете целия текст като команда. На Windowscmd.exe /cе еквивалентът.
Проблем
Фактура, която не е фактура
Купувачът си изтегля фактурата от /invoices/1041.txt. Сървърът взима името от адреса и го слепва към папката с фактурите:
string Download(string name){ string file = Path.Combine(invoices, name); return File.ReadAllText(file);}
Console.WriteLine(Download("1041.txt"));Console.WriteLine(Download("../settings.txt"));Фактура 1041: 59 €payment_api_key=demo-not-realВторото име е ../settings.txt. .. в път значи „една папка нагоре“. Пътят излезе от invoices/ и сървърът прочете settings.txt, който стои до нея, с ключа за плащанията.
Това е path traversal (обхождане на пътя): името от заявката избира файл извън папката, която кодът е имал предвид. Програмата тук си прави своя временна папка и чете само в нея; в истински сървър .. може да се качи много нива нагоре.
Идеята
Каноничен път, после проверка
Поправката има две стъпки:
- Сметни докъде стига пътят наистина. Това е каноничният път: пълен, без
.и... - Провери, че каноничният път е още в папката с фактурите. Ако не е, откажи.
string Download(string name){ string file = Path.GetFullPath(name, invoices); if (!file.StartsWith(invoices, StringComparison.Ordinal)) { return "Отказ: извън папката с фактури"; } return File.ReadAllText(file);}
Console.WriteLine(Download("1041.txt"));Console.WriteLine(Download("../settings.txt"));Фактура 1041: 59 €Отказ: извън папката с фактуриPath.GetFullPath(name, invoices) смята пълния път спрямо папката и маха . и ... Проверката е с StringComparison.Ordinal (знак по знак), а invoices завършва с /: без него и папка invoices-old би минала.Проверката не търси опасни знаци. Тя пита къде свършва пътят, затова не зависи от това за какво си се сетил.
Още по-сигурно е името изобщо да не е път. Адресът носи номер на фактура, а сървърът търси файла в своя таблица: номер, който го няма, е отказ. Това е упражнението по-долу.
По-точно, за любопитните
- Символни връзки.
GetFullPathработи само с текста на пътя. Ако в папката може да има символна връзка навън, провери иFileInfo.ResolveLinkTarget(true). - Път от корена.
Path.Combine(invoices, "/etc/hosts")връща/etc/hosts: абсолютният път замества папката. Проверката го хваща. - Архиви. Имената на файловете в zip архив също са текст отвън. Разархивирането трябва да прави същата проверка за всеки файл (капанът има име: zip slip).
- Подготовката. Програмата си прави временна папка, пише в нея двете фактури и файла с настройки, а накрая я трие. Ето началото ѝ:
string shop = Directory.CreateTempSubdirectory("shop").FullName;// Папката с фактурите, с "/" накрая: проверката го иска.string invoices = Path.Join(shop, "invoices") + Path.DirectorySeparatorChar;Directory.CreateDirectory(invoices);File.WriteAllText(Path.Join(invoices, "1041.txt"), "Фактура 1041: 59 €");File.WriteAllText(Path.Join(invoices, "1042.txt"), "Фактура 1042: 118 €");File.WriteAllText(Path.Join(shop, "settings.txt"), "payment_api_key=demo-not-real");Пробвай
Каноничният път
Довърши реда, който смята докъде стига пътят:
string Download(string name)
{
string file = Path.(name, invoices);
if (!file.StartsWith(invoices, StringComparison.Ordinal))
{
return "Отказ: извън папката с фактури";
}
return File.ReadAllText(file);
}Path.GetFullPath(name, invoices) смята пълния път без ... Проверката на следващия ред гледа него, а не текста от адреса.
Пробвай
Три имена
Какво отпечатва поправеното изтегляне за тези три имена?
Console.WriteLine(Download("1042.txt"));Console.WriteLine(Download("../invoices/1042.txt"));Console.WriteLine(Download("../settings.txt"));1042.txt е в папката. ../invoices/1042.txt излиза от invoices/ и се връща в нея: каноничният път е invoices/1042.txt, затова минава. ../settings.txt свършва извън папката: отказ.
Упражнение
Фактурата по номер
Реши задачата в редактора си.
По номер, не по име. Напиши DownloadById(id). Адресът носи само номер на фактура. Таблица в кода казва кой номер кой файл е (1041 → 1041.txt, 1042 → 1042.txt). Номер, който го няма в таблицата, е отказ, а името на файла никога не идва от заявката. Пробвай с 1041, 1042, ../settings.txt и 1043:
Фактура 1041: 59 €Фактура 1042: 118 €Отказ: няма такава фактураОтказ: няма такава фактураОще задачи
- Чужда фактура. Номерът
1042е в таблицата, но фактурата е на друг купувач. Какво още трябва да провери сървърът, преди да я върне? - Името на снимката. Промени инструмента за миниатюри така, че да записва каченото файлче под име, което програмата измисля (номер на отзива и
.jpg), а името на купувача да пази само като текст.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Списъци · Бази данни · Списъци, речници и LINQ · Недоверен вход
- Продължава в: XSS: чужд скрипт в твоята страница
- Задачи по темата: Сигурност за програмисти