Розв’язуємо безпекові проблеми у легасі-коді
Вітаю, шановний читачу! Маю понад 30 років досвіду в ІТ на різних посадах — від техліда до архітектора. Долучався до розробки складних програмних рішень у багатьох відомих українських компаніях, зокрема в Infopulse, Ciklum, 3Shape, Luxoft. Мій стек охоплює як бекенд, так і фронтенд. Маю досвід роботи з такими мовами програмування, як C/C++, Delphi, VB, Java, Perl, Python, а також із різноманітними JavaScript-фреймворками.
Останніми роками я працюю в Itera Ukraine, де спеціалізуюся на .NET та React. Основна моя діяльність — реінжиніринг легасі-застосунків, архітектурне проєктування та підвищення рівня їхньої безпеки.
У цій статті я поділюся ключовими проблемами безпеки, з якими ми зіткнулися під час міграції великого легасі-застосунка на мікросервісну архітектуру, а також ефективними способами їх усунення. Застосунок був створений понад 10 років тому і свого часу вже проходив міграцію — з десктоп-програми на веб. Кодову базу писало багато розробників різних національностей із використанням різних бібліотек та фреймворків.
Технологічний стек:
- Бекенд: .NET (C#), частково VB; деякі частини виділені в окремі сервіси на .NET REST API, Python і Node.js.
- Фронтенд: MVC + AngularJS (старі частини), Vue.js (нові частини).
Опис проблеми
У час створення застосунка питання безпеки не були серед ключових пріоритетів. Хоч певну увагу їм і приділяли, однак критичними вони не вважалися. Враховуючи, що сам застосунок пов’язаний з енергетичним сектором, після початку війни та зростання кібератак безпека стала питанням першої важливості.
Найбільша технічна конфа ТУТ!👇
Аудит, проведений на проєкті, виявив значні проблеми. Їх виокремили в одне велике завдання, яке доручили команді з трьох розробників — серед них був і я. Робота тривала пів року, адже сам проєкт доволі масштабний. Ми з колегами не були вузькими фахівцями з кібербезпеки, тож здобували необхідні знання безпосередньо в процесі. Цим практичним досвідом і хочу поділитися.
Передусім раджу ознайомитися з проєктами OWASP. Також є багато інструментів для автоматичного сканування вразливостей.
Інструменти безпеки
Першим нашим кроком було використати сканер Snyk. Він має безкоштовну версію з обмеженою функціональністю, проте рекомендую замовникам інвестувати в бізнес-ліцензію — це значно розширить можливості.
Встановити та запустити його в папці з проєктом дуже легко:
npm install -g snyk snyk test -json | snyk-to-html > snyk-report.html
У великих організаціях процеси оновлення зазвичай ідуть повільно, ніхто не поспішає апгрейдитися на останні версії бібліотек. Через це проблеми з безпекою виникають часто.
✗ High severity vulnerability found in [email protected] - Info: https://snyk.io/vuln/SNYK-DOTNET-NEWTONSOFTJSON-123456 - Introduced through: [email protected] - Змінено в: 12.0.1 - Recommendation: Upgrade to [email protected]
Річ у тім, що пакет тягне за собою інші залежності. Часто доводиться змінювати навіть таку функціональність, яку складно або й неможливо адекватно протестувати. Тому оновлення залежностей до безпечніших версій — це лише початок танців з бубнами. У нашому випадку тільки на апгрейд витратили приблизно людино-тиждень.
Варто зазначити, що Snyk здатен виявляти вразливості й у JavaScript-коді. Ми відразу інтегрували Snyk у CI/CD-пайплайн:
trigger: .. steps: - task: UseNode@1 inputs: version: '16.x' # Актуальна версія Node.js для Snyk - script: | npm install -g snyk snyk auth $(SNYK_TOKEN) displayName: 'Install and Authenticate Snyk' - script: snyk test --all-projects --severity-threshold=high displayName: 'Run Snyk Security Scan' - script: snyk monitor --all-projects displayName: 'Monitor Dependencies for Future Issues'
Наступним кроком ми підключили SonarQube для аналізу сорс-коду і також інтегрували його в CI/CD:
.. - task: SonarQubePrepare@5 inputs: SonarQube: 'SonarQubeServiceConnection' # Set up in Azure DevOps scannerMode: 'MSBuild' projectKey: 'MyCSharpApp' - script: | dotnet restore dotnet build --configuration Release displayName: 'Build Project' - task: SonarQubeAnalyze@5 displayName: 'Run SonarQube Analysis' - task: SonarQubePublish@5 displayName: 'Publish SonarQube Results'
Отриманий звіт містив сотню сторінок — проблеми варіювалися від банальних до складних. Ось приклади поширених помилок, які дуже часто трапляються в легасі-застосунку:
- SQL-ін’єкції.
- XSS (міжсайтовий скриптинг).
- Проблеми авторизації та автентифікації.
- Небезпечна робота з файлами.
- Використання застарілих і небезпечних сторонніх бібліотек.
- Відсутність суворої політики логування та моніторингу.
- Небезпечна десеріалізація.
- Помилки контролю доступу.
- Прототипне забруднення (Prototype Pollution).
- Витік інформації через заголовки, повідомлення про помилки або в коді.
Далі детальніше розглянемо характерні помилки, на які часто не звертають уваги.
1. SQL-ін’єкції
Проблема:
string query = "SELECT * FROM RiskMatrix WHERE RiskID = " + riskId + “;”; SqlCommand cmd = New SqlCommand(query, connection); SqlDataReader reader = cmd.ExecuteReader();
Вразливість полягає в тому, що riskId може містити шкідливий код, наприклад 1=1, який поверне всі записи.
Або якщо замість riskId = 5
зловмисник введе рядок: 5; DROP TABLE RiskMatrix
, тоді SQL-запит виглядатиме так:
SELECT * FROM RiskMatrix WHERE RiskID = 5; DROP TABLE RiskMatrix
Цей запит не тільки отримає дані, але й видалить таблицю RiskMatrix.
Виправлення:
string query = "SELECT * FROM RiskMatrix WHERE RiskID = @riskId"; SqlCommand cmd = new SqlCommand(query, connection); cmd.Parameters.AddWithValue("@riskId", riskId); SqlDataReader reader = cmd.ExecuteReader();
Параметризація запитів — найкращий спосіб уникнути SQL-ін’єкцій. riskId більше не є частиною SQL-команди, а передається окремо як параметр.
2. XSS (міжсайтовий скриптинг)
Проблема:
return " <div>" + userComment + "</div> ";
Цей код відображає дані з бази без жодної перевірки або обробки. У проєкті було багато місць, де користувач міг залишити опис або коментар.
Виправлення:
using System.Web; string safeOutput = HttpUtility.HtmlEncode(userComment); return " <div>" + safeOutput + "</div> ";
Використання HtmlEncode дає змогу перекодовувати спеціальні символи HTML у безпечні еквіваленти. Це гарантує, що дані, введені користувачем, не будуть сприйняті браузером як HTML або JavaScript.
Крім того, для захисту від XSS-атак ми впровадили Content Security Policy (CSP), яка забороняє виконання небезпечних скриптів. CSP можна задати як через HTTP-заголовки, так і за допомогою метатегів у HTML.
Приклад конфігурації CSP у web.config для IIS:
<configuration> <system.webServer> <httpProtocol> <customHeaders> <add name="Content-Security-Policy" value="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self'; img-src 'self' data:; object-src 'none'" /> </customHeaders> </httpProtocol> </system.webServer> </configuration>
ASP.NET MVC (до .NET Core)
В ASP.NET MVC 5 і нижче можна встановити заголовок в ActionFilter або методі Application_BeginRequest в Global.asax.cs:
Application_BeginRequest в Global.asax.cs: protected void Application_BeginRequest(object sender, EventArgs e) { HttpContext.Current.Response.Headers.Add("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self'; img-src 'self' data:; object-src 'none'"); }
Або у фільтрі MVC:
public class CspFilter : ActionFilterAttribute { public override void OnResultExecuting(ResultExecutingContext filterContext) { filterContext.HttpContext.Response.Headers.Add("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self'; img-src 'self' data:; object-src 'none'"); base.OnResultExecuting(filterContext); } }
Потім додаємо його до Controller:
[CspFilter] public class HomeController : Controller { public ActionResult Index() { return View(); } }
Відразу зазначу, що в .NET Core це робиться інакше:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { app.Use(async (context, next) => { context.Response.Headers.Add("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self'; img-src 'self' data:; object-src 'none'"); await next(); }); app.UseRouting(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); }
3. Проблеми авторизації та аутентифікації
У нашому проєкті був уразливий механізм обробки токенів доступу, який можна було підробити. Основна проблема полягала у відсутності перевірки підпису токена аутентифікації. Якщо система використовує JWT або інші токени для ідентифікації користувачів, їхній підпис обов’язково має перевірятися перед обробкою запиту. В іншому разі зловмисник може підробити токен і видати себе за іншого користувача.
Проблема:
var token = Request.Headers["AuthToken"]; var userId = DecryptToken(token); // Немає перевірки підпису! var user = GetUserById(userId);
У цьому випадку будь-який токен, навіть підроблений, проходить обробку, що дозволяє отримати доступ до системи.
Виправлення:
var token = Request.Headers["AuthToken"]; var validatedToken = ValidateAndDecodeToken(token); if (validatedToken == null) { return Unauthorized(); } var user = GetUserById(validatedToken.UserId);
Тепер система перевіряє підпис токена перед його розшифруванням, що унеможливлює підробку даних користувача.
Ще одна проблема полягала в тому, що код частково дозволяв отримати доступ до чужих даних без належної перевірки прав доступу:
public IActionResult GetAssets(int departmentId) { var user = _dbContext.Assets.Find(departmentId); return Ok(user); }
4. Небезпечна робота з файлами
Проблема: у багатьох частинах застосунку користувачам дозволяли завантажувати файли без перевірки їхнього типу та вмісту. Це створювало потенційну загрозу, оскільки хакер міг, наприклад, перейменувати виконуваний файл .exe на .txt і завантажити його на сервер. Без перевірки вмісту та розширення файлу його можна було б використати для подальших атак.
Небезпечний код:
public void ProcessRequest(HttpContext context) { HttpPostedFile file = context.Request.Files[0]; file.SaveAs(context.Server.MapPath("~/uploads/" + file.FileName)); context.Response.Write("File has been successfully uploaded."); }
Цей код приймає будь-який файл без перевірки, що може зробити сервер сховищем потенційно шкідливих файлів.
Складність полягала ще й у тому, що таких місць завантаження файлів було понад два десятки. Код не завжди був створений через копіювання, тож навіть за наявності повторів це ускладнювало виправлення. У багатьох випадках узагалі не перевірялося, чи відповідає файл очікуваному формату або чи він принаймні валідний. Тому це потребувало універсального рішення без значного рефакторингу.
Виправлення: щоб вирішити проблему, ми реалізували спеціальний HTTP-обробник на рівні IIS, який перевіряв не лише розширення файлу, а і його фактичний вміст.
Конфігурація IIS
<system.webServer> <handlers> <add name="FileUploadHandler" path="uploads/*" verb="POST" type="FileUploadHandler, MyApp" resourceType="Unspecified" /> </handlers> </system.webServer>
JSON-мапінг дозволених файлів
Оскільки різні частини системи дозволяли завантаження різних типів файлів, ми централізували перевірку через окремий JSON-файл, який містив дозволені розширення для кожного ендпоїнту. Код внизу — схематичний:
{ "mappings": [ { "path": "user/reports/*", "allowedExtensions": [".xls", ".xlsx", ".csv", ".pdf"] “maxSize” : “40 000” }, { "path": "maps/images/*", "allowedExtensions": [".jpg", ".png", ".gif"] }, { "path": "contracts/documents/*", "allowedExtensions": [".doc", ".docx", ".txt", ".pdf"] } ] }
Реалізація HTTP-обробника
public class FileUploadHandler : IHttpHandler { private readonly Dictionary<string, List<string>> _allowedFileTypes; public FileUploadHandler() { _allowedFileTypes = LoadFileMappings(); } public void ProcessRequest(HttpContext context) { HttpPostedFile file = context.Request.Files[0]; string requestPath = context.Request.Path; if (!IsValidFile(file, requestPath)) { context.Response.StatusCode = 400; context.Response.Write("Invalid file or format !"); return; } file.SaveAs(context.Server.MapPath("~/uploads/" + Path.GetFileName(file.FileName))); context.Response.Write("File has been uploaded."); } private bool IsValidFile(HttpPostedFile file, string requestPath) { string extension = Path.GetExtension(file.FileName).ToLower(); if (!_allowedFileTypes.Any(mapping => requestPath.StartsWith(mapping.Key) && mapping.Value.Contains(extension))) { return false; } return ValidateFileContent(file, extension); } private bool ValidateFileContent(HttpPostedFile file, string extension) { try { byte[] fileSignature = new byte[4]; file.InputStream.Read(fileSignature, 0, 4); if (IsExecutableFile(fileSignature)) { return false; } switch (extension) { case ".json": using (var reader = new StreamReader(file.InputStream)) { string content = reader.ReadToEnd(); JObject.Parse(content); // Перевірка на валідність JSON } break; case ".pdf": if (!fileSignature.SequenceEqual(new byte[] { 0x25, 0x50, 0x44, 0x46 })) // PDF signature return false; break; // lots of other case.. } } catch (Exception) { return false; } return true; } private bool IsExecutableFile(byte[] signature) { // Перевірка на виконувані файли (EXE, DLL) byte[][] executableSignatures = new byte[][] { new byte[] { 0x4D, 0x5A } // EXE/DLL (MZ Header) }; return executableSignatures.Any(sig => signature.Take(sig.Length).SequenceEqual(sig)); } private Dictionary<string, List<string>> LoadFileMappings() { string configPath = HttpContext.Current.Server.MapPath("~/App_Data/FileMappings.json"); string json = File.ReadAllText(configPath); var mappings = JObject.Parse(json)["mappings"].ToObject<List<FileMapping>>(); return mappings.ToDictionary(m => m.Path, m => m.AllowedExtensions); } public bool IsReusable => false; } public class FileMapping { public string Path { get; set; } public List<string> AllowedExtensions { get; set; } }
Що покращила ця реалізація
Гнучкість: адміністратори можуть змінювати дозволені типи файлів без редагування коду.
Безпека: перевіряється не лише розширення файлів, а і їхній фактичний вміст.
Зручність підтримки: одна точка керування всіма дозволеними форматами для різних ендпоїнтів.
Було також реалізовано додаткові засоби захисту:
- обмеження на максимальний розмір файлу;
- вимкнення виконання файлів у каталозі завантажень;
- використання антивірусного сканування перед збереженням файлів.
Завдяки цим заходам нам вдалося запобігти завантаженню шкідливих файлів і підвищити стабільність системи.
5. Витік інформації через заголовки та повідомлення про помилки
Проблема: деталізовані повідомлення про помилки можуть розкрити критичну інформацію, таку як внутрішня структура сервера, шляхи до файлів або навіть SQL-запити. З одного боку, це зручно для розробника, але водночас може стати джерелом цінної інформації для зловмисника.
try { var data = File.ReadAllText("config.json"); } catch (Exception ex) { return BadRequest(ex.ToString()); }
У випадку помилки користувач отримає повний стек-трейс, що може допомогти хакеру зрозуміти структуру системи та знайти нові вразливості.
Виправлення:
try { var data = File.ReadAllText("config.json"); } catch (Exception ex) { _logger.LogError($"Error while reading config: {ex.Message}"); return BadRequest("Error while processing the request. Please contact support."); }
Деталі помилок тепер логуються на сервері, але не відображаються користувачеві. Це унеможливлює витік критичної інформації.
Окрім того, ми відключили розголошення інформації через заголовки в ASP.NET Core:
app.Use(async (context, next) => { context.Response.Headers.Remove("X-Powered-By"); context.Response.Headers.Add("X-Content-Type-Options", "nosniff"); context.Response.Headers.Add("X-Frame-Options", "DENY"); await next(); });
Це запобігає атакам Clickjacking, а також зменшує ризик витоку інформації про сервер.
6. Небезпечна десеріалізація
Проблема: небезпечна десеріалізація виникає, коли програма без перевірки завантажує та розшифровує об’єкти зі сторонніх джерел. У цьому випадку BinaryFormatter під час десеріалізації може виконати довільний код — це дозволяє зловмиснику впровадити шкідливі об’єкти, які можуть отримати доступ до файлів, виконати команди або змінити дані в системі.
BinaryFormatter formatter = new BinaryFormatter(); using (FileStream stream = new FileStream("userData.dat", FileMode.Open)) { var user = (User)formatter.Deserialize(stream); }
Виправлення: замість BinaryFormatter ми використали JsonConvert, але обмежуємо можливість десеріалізувати довільні типи, встановивши TypeNameHandling = TypeNameHandling.None. Це запобігає виконанню довільного коду та обмежує десеріалізацію лише очікуваними структурами даних, що значно підвищує рівень безпеки.
var settings = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.None, Formatting = Formatting.Indented }; var user = JsonConvert.DeserializeObject<User>(jsonString, settings);
7. Витік секретних ключів у коді
Проблема: розробники часто залишають у коді секретні ключі API, токени доступу або паролі, особливо під час тестування. Якщо такі дані потраплять у репозиторій (наприклад, GitHub), їх можуть знайти зловмисники, навіть коли сам репозиторій закритий. Наприклад:
public static string ApiKeyDebug = "00000000-1111-2222-3333-444444444444"; var connectionString = "Server=myserver;Database=mydb;User Id=admin;Password=admin;";
Такі дані можуть випадково потрапити в загальнодоступні репозиторії або журнали помилок, що відкриє зловмисникам прямий доступ до критично важливих систем.
Виправлення:
1. Виносьте секрети в змінні середовища або захищені сховища
Замість того щоб зберігати ключі в коді, використовуйте змінні середовища або Azure Key Vault чи AWS Secrets Manager:
var apiKey = Environment.GetEnvironmentVariable("MY_APP_API_KEY");
2. Використовуйте файли конфігурації, але не зберігайте їх у Git
У appsettings.json можна вказати placeholder, а реальні значення зберігати в appsettings.Development.json, який не завантажується в репозиторій.
{ "ConnectionStrings": { "DefaultConnection": "Server=myserver;Database=mydb;User Id=${DB_USER};Password=${DB_PASSWORD}" } }
8. Прототипне забруднення в AngularJS
Ще одна проблема, з якою ми зіткнулися в JavaScript-середовищі, — це прототипне забруднення (Prototype Polution). Ця вразливість дає зловмиснику змогу змінювати глобальні прототипи (Object.prototype
), що може призвести до серйозних наслідків: неправильна логіка авторизації, виконання шкідливого коду та навіть відмова в обслуговуванні (DoS-атаки).
Проблема:
Уразливий бекенд API
У контексті AngularJS ця атака може впливати на об’єкти, що передаються у двостороннє зв’язування даних ($scope
) або приходять через API.
[HttpPost] public IActionResult UpdateUser([FromBody] User user) { _dbContext.Users.Update(user); _dbContext.SaveChanges(); return Ok(); }
Якщо зловмисник надішле спеціально створений об’єкт,
{ "__proto__": { "isAdmin": true } }
він змінить глобальний Object.prototype
, і кожен об’єкт у системі отримає isAdmin: true
, навіть якщо в нього немає такого поля.
Уразливий фронтенд в AngularJS
Уявімо, що AngularJS-код працює з цим API та отримує дані з JSON.parse()
без перевірки:
$http.post('/api/update-user', userInput) .then(response => { $scope.user = response.data; });
Атакуючий передає в API:
{ "__proto__": { "isAdmin": true } }
Це змінює глобальний об’єкт Object.prototype
:
console.log({}.isAdmin); // true!
Тепер будь-який код, який перевіряє if (user.isAdmin)
, отримає true
, навіть якщо користувач насправді не має таких прав!
Виправлення:
1. Використовуйте Object.create(null) для захисту.
let userData = Object.create(null);
Це гарантує, що у вашого об’єкта не буде __proto__
, і його неможливо змінити глобально.
2. Перевіряйте вхідні дані перед обробкою
Перед тим як передати введені користувачем дані у фронтенд або бекенд, видаляйте небезпечні властивості.
На бекенді (.NET)
public static bool IsValidJson(string json) { if (json.Contains("__proto__") || json.Contains("constructor")) { return false; } return true; }
На фронтенді (AngularJS)
function sanitizeInput(input) { if (typeof input === 'object' && input !== null) { if ('__proto__' in input || 'constructor' in input) { throw new Error("Prototype Pollution detected!"); } Object.keys(input).forEach(key => sanitizeInput(input[key])); } }
3. Використовуйте бібліотеки для очищення даних
Щоб автоматично видаляти шкідливі властивості, можна використати DOMPurify або lodash.cloneDeep():
З lodash
import _ from 'lodash'; let safeData = _.cloneDeep(userInput);
З DOMPurify
import DOMPurify from 'dompurify'; let sanitizedData = DOMPurify.sanitize(userInput);
4. Використовуйте hasOwnProperty() для перевірки властивостей
Коли перевіряєте, чи є поле в об’єкті, замість if (obj.property)
використовуйте hasOwnPropert(y)
:
Небезпечно:
if (user.isAdmin) { // isAdmin може бути в Object.prototype! grantAccess(); }
Безпечно:
if (user.hasOwnProperty('isAdmin') && user.isAdmin) { grantAccess(); }
Тестування API за допомогою OWASP ZAP
Вразливості, такі як SQL-ін’єкції, XSS, небезпечні заголовки тощо, можна виявити за допомогою OWASP ZAP (Zed Attack Proxy) — потужного інструмента для автоматизованого тестування API.
Виконуйте сканування за допомогою команди:
zap-cli quick-scan -r https://your-api-endpoint.com
Або використовуйте Docker:
docker run -u zap -p 8080:8080 -i owasp/zap2docker-stable zap.sh -daemon -host 0.0.0.0 -port 8080
Висновки
Питанню безпеки, так само як і якості коду, потрібно приділяти належну увагу із самого початку розробки. Інакше проблеми накопичуються, як снігова куля, а їхнє вирішення згодом потребує значних зусиль. Якщо у вас є подібний легасі-застосунок, раджу провести аудит безпеки та впровадити DevSecOps-підхід у розробку.
5 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів