Минимальный чек-лист безопасности при разработке Windows-приложений

· Обновлено: · · Разработка Windows, Безопасность, Проектирование, C# / .NET, Win32

История изменений (4 обновлений, последнее 31 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Текст статьи исправлен, чтобы соответствовать японскому оригиналу.
Восстановлены пропущенные источники в списке литературы. Утверждения статьи не менялись.
Исправлен URL CryptProtectData в списке источников. Утверждения статьи не менялись.
Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
Первая публикация
Цитирование статьи(DOI: 10.5281/zenodo.21619679)

Статья заархивирована на Zenodo. Ниже приведены DOI, который всегда ведёт к последней версии, и DOI, закреплённый за версией, которую вы читаете.

Го Комура (2026). Минимальный чек-лист безопасности при разработке Windows-приложений. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21619679 https://comcomponent.com/ru/blog/2026/03/14/001-windows-app-security-minimum-checklist/

DOI (последняя версия)
10.5281/zenodo.21619679
DOI (эта версия)
10.5281/zenodo.21619680

Скачать чек-лист в Excel

Содержимое файла совпадает с чек-листом перед релизом из главы 4 (8 категорий, 32 пункта). Отличия только в том, что есть колонки Status и Notes, и что японский и английский лежат на двух листах: Checklist-ja и Checklist-en. Читать по ходу статьи удобнее главу 4; раздавать как запись ревью — Excel-версию.

Разговор про безопасность Windows-приложений быстро раздувается. Zero Trust, EDR, SBOM (Software Bill of Materials, перечень программных компонентов), работа с сертификатами, управление уязвимостями. Всё это важно, но на практике ещё до этого есть база, которую не хочется пропускать.

Особенно в приложениях ниже закрывать дыры в базе выгоднее, чем сразу строить «продвинутую защиту».

  • десктопные приложения на WPF / WinForms / WinUI
  • Win32-приложения на C++ / C#
  • интеграция с оборудованием, файловый обмен, подключения к БД, внутренние утилиты
  • бизнес-приложения с автообновлением
  • конфигурации со службами Windows или вспомогательными EXE

В разработке Windows-приложений реалистичнее сначала не оставлять явно опасных дыр, чем пытаться сразу довести всё до идеала. Ниже — минимальные пункты, которые не стоит пропускать, в порядке проектирования, реализации, распространения и эксплуатации, в виде, удобном для проверки.

Как устроена эта статьяСначала закрываем пробелы в базе, а не продвинутую защиту, и смотрим пункты в порядке проектирования, реализации, распространения и эксплуатации.сначалаПродвинутая защита (Zero Trust и т.п.)Закрыть пробелы в базеПроектированиеРеализацияРаспространениеЭксплуатация

Рис. 1: Сначала база, потом продвинутая защита; смотрим проектирование, реализацию, распространение и эксплуатацию.

1. Сначала вывод

  • Первое, что не стоит пропускать: не запрашивать лишние права администратора, подписывать код, не хранить секреты в открытом виде, не отключать проверку сертификатов.
  • У Windows-приложения сам дистрибутив — поверхность атаки. Безопаснее смотреть на всё: EXE / DLL / MSI / MSIX / модуль автообновления.
  • ServerCertificateValidationCallback => true, plaintext-строки подключения, небрежная загрузка вроде LoadLibrary("foo.dll") и SQL через конкатенацию строк — то, чего стоит избегать даже на минимальной планке.
  • Если права администратора нужны только части операций, безопаснее не повышать права всего приложения, а вынести эту часть в отдельный EXE или службу.
  • Для приложения, которое ставят на Windows, лучше по умолчанию закладывать подпись + метку времени. Это не только доверие пользователей: проще ловить подмену и объяснять это на эксплуатации.
  • Секреты на диске в зависимости от задачи закрывают DPAPI / ProtectedData или Credential Locker. Как минимум стоит уйти от plaintext в appsettings.json.
  • «Чем больше логов, тем лучше» — нет. Если оставлять токены, пароли, строки подключения, персональные данные, полное тело запроса как есть, сам лог становится источником инцидента.

Минимальная безопасность — не про то, чтобы добавить особую функцию, а про то, чтобы не оставлять опасное поведение по умолчанию и небрежную реализацию.

Что имеется в виду под минимальной безопасностьюМинимальная безопасность — не добавление особых функций, а отказ оставлять опасное поведение по умолчанию и небрежную реализацию.не в этом смысл минимумаименно этоДобавить особую функциюМинимальная безопасностьНе оставлять опасные умолчания и небрежный код

Рис. 2: Минимальная планка — не новые функции, а отказ оставлять опасные умолчания и небрежный код.

Карта знаний этой статьи

Статья сводит в чек-лист минимальные меры безопасности, от которых не стоит отказываться перед выпуском, для бизнес-приложений Windows на WPF, WinForms, WinUI, C++ и C#. В центре: не ставить всему приложению requireAdministrator, а выносить только нужные операции в отдельный процесс или службу; подписывать дистрибутив с меткой времени и не распространять без подписи и не отключать проверку сертификатов на постоянной основе; не класть секреты в открытую конфигурацию, а защищать их, например, через DPAPI. Дополнительно: конкатенация строк в SQL ведёт к SQL-инъекции, её предотвращают плейсхолдеры; загрузка DLL только по имени открывает hijacking порядка поиска; вывод конфиденциальных данных в лог делает сам лог каналом утечки.

Карта знаний минимального чек-листа безопасности Windows-приложенийСхема связей между минимальными пунктами безопасности: обращение с правами администратора, проверка подписи кода и обновлений, защита секретов, риски ввода и выполнения вроде SQL-инъекции и загрузки DLL, утечка конфиденциальных данных через логинастраиваетсянастраиваетсяне рекомендуетсярекомендуется дляможет вызватьможет вызватьпредотвращаетснижаетпредотвращаетрекомендуется длярекомендуется длятребуетне рекомендуетсяможет вызватьпредотвращаетможет вызватьрекомендуется длятребуетиспользуеттребуетрекомендуется дляправа администраторасертификат подписи кодасекреты приложенияasInvoker (уровень запуска)requireAdministrator (уровень запуска)настольное приложение интерактивного пользователяотделение операций с правами администратораподмена DLL через порядок поисказагрузка DLL только по именинеподписанный бинарный файлметка времени подписи кодаистечение сертификатапроверка подписи и хеша обновленияприменение непроверенного обновленияпиннинг сертификата (certificate pinning)постоянный обход проверки сертификатапроверка отзыва сертификатаконкатенация SQL-строкSQL-инъекцияплейсхолдер (prepared statement)запись секретов в журналраскрытие секретов через журналыDPAPIхранение секретов в открытом видеслужба Windows

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 21, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

2. Охват статьи и что значит «минимум»

2.1. Что входит в статью

Речь о таких Windows-приложениях.

  • десктопные приложения на WPF / WinForms / WinUI
  • Win32-приложения на C++ / C#
  • внутренние утилиты, интеграция с оборудованием, мониторинг
  • конфигурации со вспомогательными EXE, службами Windows, апдейтерами
  • бизнес-ПО, которое распространяют как EXE / MSI / MSIX

«Минимум» здесь — не финальное состояние, которое проходит аудит, а пункты, без которых инцидент случается на ровном месте.

Что в этой статье значит «минимум»Минимум здесь — не финальное состояние для аудита, а пункты, без которых инцидент случается на ровном месте.здесь не этоименно этоФинальное состояние, проходящее аудит«Минимум» в этой статьеПункты, без которых случается инцидент

Рис. 3: «Минимум» — не аудит-ready финал, а то, без чего инцидент случается сам.

Зафиксируем и предпосылки примеров кода. Примеры на C# рассчитаны на .NET 8 и новее, примеры на C++ — на Win32 API. На .NET Framework 4.8 идея та же, но рекомендуемый способ записи в части мест уже другой. Типичный пример — ServicePointManager в 3.6: в новом коде исходят из IHttpClientFactory и HttpClient. Когда перечитываете старый код, смотрите именно сюда.

2.2. Что не входит

Есть и то, что мы сознательно выносим за основной фокус.

  • Zero Trust на всю компанию
  • комплексная эксплуатация EDR / SIEM / DLP / MDM
  • детальный hardening драйверов ядра
  • криптосхема с нуля
  • продвинутый threat analysis и процедуры форензики

То есть это не «масштабная программа безопасности организации», а базовая линия, которую разработчик Windows-приложения ещё может сам закрыть перед релизом.

Что входит и что нетМасштабные меры на всю организацию остаются за кадром; статья про базовую линию, которую разработчик ещё может закрыть сам перед релизом.в этой статье нетоб этомМасштабные меры на всю организациюОхват статьиБазовая линия, которую разработчик ещё может закрыть сам

Рис. 4: Не корпоративная программа, а база, которую разработчик закрывает сам перед релизом.

3. Чек-лист, с которого начать

До тонких споров положим таблицу, по которой видно целое. Уже по ней понятно, куда смотреть.

3.1. Общая картина

Что проверить Минимальные действия Типичная ошибка
Права выполнения По умолчанию asInvoker; операции, которым нужно повышение, вынести отдельно Делать всё приложение requireAdministrator
Доверие к дистрибутиву Подписывать EXE / DLL / MSI / MSIX и ставить метку времени Раздавать без подписи
Обновление Зафиксировать источник, ловить подмену через HTTPS и проверку подписи Скачать по HTTP и сразу перезаписать
Секреты Не класть секреты в исходники и plaintext-конфиг; DPAPI / Credential Locker и т.п. API-ключи и строки подключения в конфиге открытым текстом
Сеть HTTPS, не отключать проверку сертификатов Постоянно пропускать проверку через return true
Внешний ввод Проверять всё: SQL, файлы, IPC, URI, CSV, JSON и остальное Пропускать без проверки, потому что «это внутренний инструмент»
Загрузка DLL Абсолютный путь, SetDefaultDllDirectories, безопасный порядок поиска LoadLibrary("foo.dll") из текущего каталога
Логи Маскировать токены, пароли, PII; разделять ошибки для пользователя и для себя Показывать и сохранять детали исключений и строки подключения как есть
Зависимости Постоянно обновлять SDK, NuGet, VC++ runtime, OSS-зависимости Зафиксировать версии на годы и не следить за уязвимостями

3.2. Права: по умолчанию asInvoker

В Windows-приложении это первое, что стоит пересмотреть. Если всё приложение работает от администратора, баги, подмена DLL, неверное чтение конфигурации и дыры во внешнем вводе исполняются уже с этими высокими правами.

Чем опасно поднимать права всего приложенияЕсли всё приложение работает от администратора, баги, подмена DLL, неверное чтение конфигурации и дыры во вводе исполняются с этими же высокими правами.Всё приложение от администратораБагиПодмена DLLОшибка чтения конфига / дыра во вводеИсполняется уже с высокими правами

Рис. 5: Если поднять права всего приложения, все его дыры исполняются уже с администратором.

Базовая стратегия такая.

  • Обычное UI-приложение — asInvoker
  • Операции, которым нужны права администратора, вынести в отдельный процесс или службу
  • Повышать права только в нужный момент
  • Ввод, который уходит во вспомогательный EXE или службу, тоже проверять

Если десктопное приложение в обычной работе только смотрит и правит данные, а права администратора нужны лишь для установки или смены настроек брандмауэра, безопаснее не ставить requireAdministrator на всё приложение, а свести повышение в broker.

<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
  <security>
    <requestedPrivileges>
      <requestedExecutionLevel level="asInvoker" uiAccess="false" />
    </requestedPrivileges>
  </security>
</trustInfo>

«Проще, если оно просто работает от администратора» — обычно потом аукается. Держите минимальные права и вырезайте только те операции, без которых никак: радиус инцидента заметно сужается.

Как вынести только те операции, которым нужно повышениеОбычный UI работает как asInvoker, операции с правами администратора уходят в broker (отдельный EXE или служба), повышение — только в нужный момент.запрос только в нужный моментUI-приложение (asInvoker)broker (отдельный EXE / служба)Выполняется только то, чему нужно повышениеВвод в broker тоже проверяется

Рис. 6: Обычная работа на asInvoker, повышение — только в broker; радиус инцидента меньше.

3.3. Подписывать бинарники и установщик

В Windows решает доверие к дистрибутиву. Пользователь трогает не исходники, а EXE, DLL, MSI, MSIX, апдейтер. Без подписи слабеют и объяснение на эксплуатации, и обнаружение подмены, и спокойствие при раздаче.

Пользователь трогает дистрибутив, не исходникиПользователь работает с EXE, DLL, установщиком и апдейтером; без подписи слабеют и обнаружение подмены, и объяснение на эксплуатации.Что трогает пользовательEXE / DLLMSI / MSIXАпдейтерБез подписи слабее и обнаружение подмены, и объяснение

Рис. 7: Поверхность атаки — сам дистрибутив; без подписи не на чем строить доверие.

На минимуме стоит посмотреть сюда.

  • Подписывать EXE / DLL / MSI / MSIX
  • Подписывать не только установщик, но и вспомогательные бинарники обновления
  • Ставить метку времени
  • Срок сертификата и порядок его продления включить в процедуру релиза

Подпись без метки времени особенно часто ломает проверку после истечения сертификата. «Подписали — и хватит» недостаточно: стабильнее заложить в процедуру релиза связку подпись + метка времени.

Подпись и метка времениПодпись без метки времени после истечения сертификата часто не проходит проверку; связка подпись плюс метка времени в процедуре релиза стабильнее.Только подписьПосле истечения сертификата проверка часто ломаетсяПодпись + метка времениПосле истечения проверять прощеЗаложить в процедуру релиза

Рис. 8: Не останавливайтесь на подписи: метку времени тоже внесите в процедуру релиза.

Для MSIX подпись пакета — обязательное условие. При раздаче MSI / EXE тоже стоит подписывать хотя бы сам установщик и основные исполняемые файлы.

3.4. Зафиксировать канал обновления и ловить подмену

В современном Windows-приложении канал обновления живёт дольше первой установки. Если сделать его небрежно, даже аккуратно написанное приложение упрётся в апдейтер как в самое слабое место.

Канал обновления живёт дольше установкиПервая установка бывает один раз, канал обновления используется долго; если сделать его небрежно, апдейтер становится самым слабым местом приложения.один разиспользуется долгоПервая установкаСрок жизни приложенияКанал обновленияНебрежно — становится самым слабым местом

Рис. 9: Канал обновления живёт дольше установки; небрежная реализация делает его главной дырой.

В обновлениях на минимуме стоит продумать эти пять пунктов.

  • Файлы обновления забирать только по HTTPS
  • Проверять подпись или хеш скачанного обновления
  • Не давать бесконечно подменять URL источника через код или настройки
  • Подписывать сам модуль обновления
  • Заранее решить откат и восстановление при сбое

Если можно взять MSIX + App Installer, механизм обновления проще опереть на ОС. Если апдейтер свой, нужно проверить и безопасность канала, и подлинность дистрибутива. HTTPS закрывает «путь», но не отвечает на вопрос «этот файл действительно наш».

Две проверки в обновленииВ своём апдейтере нужны и HTTPS для канала, и проверка подписи или хеша скачанного обновления, чтобы убедиться, что файл ваш.Забрать обновление по HTTPSПроверить подпись или хешПрименять только то, что прошло проверкуHTTPS закрывает только каналУбедиться, что это наш выпуск

Рис. 10: HTTPS не отвечает на вопрос о подлинности; применять только после проверки подписи или хеша.

3.5. Не класть секреты в исходники и plaintext-конфиг

На практике именно здесь чаще всего случается инцидент. Из соображений «это внутренний инструмент» или «мы всё равно просто раздаём exe» строки подключения, API-ключи, учётные данные общей папки и общие токены оказываются в исходниках или конфиге.

На минимуме так хранить не стоит.

  • API-ключ прямо в исходниках
  • Пароль открытым текстом в appsettings.json или app.config
  • Строка подключения, попавшая в репозиторий
  • Схема, где ключ расшифровки лежит рядом с шифротекстом
  • Одни и те же фиксированные учётные данные на всех пользователей

Реалистичные варианты для Windows-приложения обычно сводятся к этим четырём.

  • Нужно сохранить учётные данные Windows Для packaged desktop app / WinUI имеет смысл смотреть Credential Locker
  • Нужно зашифровать секрет локально Для Win32 / .NET — DPAPI / ProtectedData
  • К цели можно ходить через Windows-аутентификацию или integrated authentication Если можно, пусть приложение вообще не хранит пароль
  • Секретами можно управлять в облаке или на сервере Предпочитайте схему, в которой долгоживущий секрет не зашит в клиент

В C# уже одно использование DPAPI заметно лучше plaintext. Если расписать сохранение и чтение целиком, получается так.

// C# / .NET 8. ProtectedData работает только на Windows;
// в .NET нужен NuGet-пакет System.Security.Cryptography.ProtectedData.
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;

public static class SecretStore
{
    // То же значение нужно и при расшифровке. null тоже сработает, но с entropy безопаснее.
    private static readonly byte[] Entropy = [0x4b, 0x53, 0x2d, 0x76, 0x31];

    public static void Save(string path, string secretText)
    {
        byte[] plaintext = Encoding.UTF8.GetBytes(secretText);

        byte[] ciphertext = ProtectedData.Protect(
            plaintext,
            Entropy,
            DataProtectionScope.CurrentUser);

        // Сырые байты в файл можно, но для конфига удобнее Base64.
        File.WriteAllText(path, Convert.ToBase64String(ciphertext));

        CryptographicOperations.ZeroMemory(plaintext);
    }

    public static string Load(string path)
    {
        byte[] ciphertext = Convert.FromBase64String(File.ReadAllText(path));

        // Если пользователь или entropy не те же, что при сохранении, будет CryptographicException.
        byte[] plaintext = ProtectedData.Unprotect(
            ciphertext,
            Entropy,
            DataProtectionScope.CurrentUser);

        try
        {
            return Encoding.UTF8.GetString(plaintext);
        }
        finally
        {
            CryptographicOperations.ZeroMemory(plaintext);
        }
    }
}

Со стороны вызова это выглядит так.

string path = Path.Combine(
    Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
    "SampleApp",
    "token.dat");

Directory.CreateDirectory(Path.GetDirectoryName(path)!);

SecretStore.Save(path, "example-api-key");
string restored = SecretStore.Load(path);

Важно не «зашифровали — значит безопасно», а то, кто может расшифровать, и это нужно решить в архитектуре. CurrentUser и LocalMachine значат очень разное. CurrentUser расшифрует только тот пользователь, который сохранил данные; LocalMachine — любой на этой машине. Если служба крутится под другой учёткой или пользователей переключают, не решив это заранее, потом упрётесь либо в «не читается», либо в «читается у всех».

Кто может расшифровать данные в DPAPIПри CurrentUser расшифрует только пользователь, который сохранил данные; при LocalMachine — любой на этой машине. Кто может расшифровать, нужно решить заранее.CurrentUserLocalMachineРешить в архитектуре, кто может расшифроватьТолько пользователь, который сохранилЛюбой на этой машинеНе решить заранее — потом упрётесь

Рис. 11: У DPAPI область расшифровки задаёт scope; сначала решите, кто может расшифровать.

Ещё: ключ DPAPI лежит в профиле пользователя, и в документации прямо сказано, что если профиль не загружен (например, во время impersonation), расшифровка падает. Если думаете вызывать DPAPI из службы, проверьте и это.

Ключ DPAPI и профиль пользователяКлюч DPAPI лежит в профиле пользователя, поэтому если профиль не загружен, например во время impersonation, расшифровка падает.Профиль не загружен (impersonation и т.п.)Ключ DPAPIЛежит в профиле пользователяРасшифровка падаетИз службы это нужно проверить отдельно

Рис. 12: Ключ DPAPI в профиле; без загруженного профиля расшифровать нельзя.

Для подключения к SQL Server в on-premises первым кандидатом часто бывает Windows-аутентификация. Если без учётных данных в строке подключения никак, хотя бы держите Persist Security Info=False и не оставляйте их в plaintext-конфиге.

3.6. Сеть — HTTPS по умолчанию, проверку сертификатов не отключать

Лазейка «только на время разработки» так и уезжает в прод. Инциденты вокруг сети почти всегда из этой схемы.

В поставке особенно часто остаётся код или настройки такого вида.

  • ServicePointManager.ServerCertificateValidationCallback += ... => true
  • HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
  • Поставка с выключенной проверкой отзыва
  • Код под самоподписанный сертификат для разработки остаётся в проде

Минимальная политика простая.

  • Прод идёт по HTTPS
  • Не пропускать проверку сертификата всегда
  • Если ослабление проверки всё же нужно, ограничить его конкретным хостом и сертификатом
  • Код обхода для разработки гарантированно выкидывать условиями сборки или настройками
  • В .NET помнить и про проверку отзыва

Плохой пример обычно выглядит так.

ServicePointManager.ServerCertificateValidationCallback +=
    (_, _, _, _) => true;

На вид удобно, но по сути это близко к «это HTTPS-соединение пройдёт к кому угодно». Без проверки сертификата HTTPS остаётся почти пустой оболочкой.

Запись зависит от того, какой .NET вы берёте за основу

Глобальная настройка через ServicePointManager — это ещё .NET Framework. В новом коде берите HttpClient из IHttpClientFactory, а TLS-настройки кладите в SocketsHttpHandler или HttpClientHandler.

Но оставлять старый API со словами «уже не действует» опасно. В документации Microsoft сказано, что ServicePointManager.ServerCertificateValidationCallback начиная с .NET 9 мапится на RemoteCertificateValidationCallback в SocketsHttpHandler.SslOptions. То есть одна строка => true где-то в стороне может пропустить и трафик HttpClient.

Как старый callback дотягивается до нынешнего трафикаServerCertificateValidationCallback в ServicePointManager начиная с .NET 9 мапится на проверку в SocketsHttpHandler, поэтому одна строка true может пропустить и трафик HttpClient..NET 9 и новее: мапитсяПроверочный callback в ServicePointManagerПроверка на стороне SocketsHttpHandlerДействует и на трафик HttpClientОдна строка true может выхолостить всю сеть

Рис. 13: Пропуск проверки, повешенный на старый API, через маппинг дотягивается и до HttpClient.

Если проверку всё же нужно ослабить точечно, не ставьте глобальную настройку на весь процесс: замкните её на этот handler.

// C# / .NET 8. Исключение только для конкретного хоста и сертификата.
// Даже «только для разработки» без ограничения цели это то же самое, что выключить проверку глобально.
using System;
using System.Linq;
using System.Net.Http;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;

// Thumbprint целевого сертификата. Можно читать из настроек.
const string ExpectedThumbprint = "2B0C4E6A8D1F3B5D7F9A1C3E5A7C9E1B3D5F7A91";

var handler = new HttpClientHandler
{
    CheckCertificateRevocationList = true,   // Ходить за отзывом. По умолчанию false
    ServerCertificateCustomValidationCallback = (request, certificate, chain, errors) =>
    {
        if (errors == SslPolicyErrors.None)
        {
            return true;
        }

        // Допускаем только «этот компьютер не доверяет внутреннему CA».
        // Нет сертификата или не совпадает имя хоста — нет
        if (errors != SslPolicyErrors.RemoteCertificateChainErrors)
        {
            return false;
        }

        // Фиксируем собеседника и сертификат
        if (request.RequestUri?.Host != "device.internal.example"
            || certificate is null
            || !string.Equals(certificate.Thumbprint, ExpectedThumbprint,
                              StringComparison.OrdinalIgnoreCase))
        {
            return false;
        }

        // Даже у закреплённого сертификата не принимаем истечение и отзыв.
        // Иначе останется путь пользоваться сертификатом, который отозвали из-за утечки ключа
        return chain is not null
            && chain.ChainStatus.All(s =>
                   s.Status is X509ChainStatusFlags.NoError
                            or X509ChainStatusFlags.UntrustedRoot
                            or X509ChainStatusFlags.PartialChain);
    },
};

using var client = new HttpClient(handler);

ExpectedThumbprint задайте константой или из настроек — это thumbprint целевого сертификата.

Важно не делать вид, что errors и chain.ChainStatus можно не смотреть. Если при совпавшем thumbprint сразу вернуть true, проверка пройдёт и после истечения сертификата, и после отзыва из-за утечки ключа. Пиннинг значит «верим только этому одному», а не «этому одному верим при любых обстоятельствах». В коде выше допускаются только UntrustedRoot и PartialChain (внутренний CA не установлен на этот компьютер); NotTimeValid (истёк) и Revoked (отозван) по-прежнему отказ.

Чтобы смотреть отзыв, нужен CheckCertificateRevocationList = true (по умолчанию false, отзыв не проверяется). Наоборот, если внутренний CA не публикует ни CRL, ни OCSP, соединение отвалятся по RevocationStatusUnknown. Это правильное поведение. Если средства проверки отзыва нет, закройте дыру коротким сроком сертификата или каналом, по которому можно заново раздать значение пиннинга. Самое опасное состояние — «пиннить долгоживущий сертификат, который нельзя отозвать».

Как решать при пиннингеБез ошибок — пропускаем; ошибки кроме цепочки — отказ; сверяем хост и thumbprint; по состоянию цепочки истечение и отзыв тоже отказ.NoneНе ошибка цепочкиТолько ошибка цепочкиНе совпалоСовпалоИстёк, отозван и т.п.Только внутренний CA не установленСмотрим errorsПропуститьОтказатьСверить хост и thumbprintСмотрим состояние цепочки

Рис. 14: Даже при пиннинге ошибки не игнорируем: истечение и отзыв остаются отказом.

3.7. Весь внешний ввод считать недоверенным

Windows-приложение — не веб, поэтому validation ввода часто делают слабо. На деле точек входа больше, чем кажется.

  • Пути к файлам
  • CSV / Excel / JSON / XML
  • Аргументы командной строки
  • named pipe / socket / COM / RPC / gRPC
  • Строки, которые уходят в БД
  • Значения реестра
  • Буфер обмена
  • URL / deep link
  • Данные от внешнего устройства или SDK

На минимуме не пропускайте эти три пункта.

  1. SQL всегда параметризовать Не собирать SQL конкатенацией строк.
  2. Путь нормализовать, потом использовать Путь от пользователя не отдавать напрямую в удаление, перезапись, распаковку.
  3. При чтении внешнего файла ограничивать размер и проверять формат «Открылось» не значит «безопасно».

На SQL это выглядит так — вот чего избегать.

var sql = "SELECT * FROM Users WHERE Name = '" + userName + "'";

Хотя бы на минимуме сводите к такому виду.

using System.Data;
using Microsoft.Data.SqlClient;

using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT * FROM Users WHERE Name = @name";
cmd.Parameters.Add("@name", SqlDbType.NVarChar, 256).Value = userName;

«Это внутренний инструмент, вводу можно доверять» — довольно опасная предпосылка. В реальности приходят битые CSV, неожиданные имена файлов, старые данные из БД, опечатки оператора и недоделанный JSON, который написал другой инструмент.

Во внутренний инструмент тоже приходит битый вводДаже во внутренний инструмент приходят битые CSV, странные имена файлов, старые данные БД, опечатки и недоделанный JSON, поэтому весь внешний ввод нужно проверять как недоверенный.Битый CSVВвод в приложениеНеожиданное имя файлаОпечатка / старые данныеВсё проверять как недоверенный ввод

Рис. 15: Во внутренний инструмент битый ввод тоже приходит; проверяйте на каждом входе.

3.8. Не оставлять источник загрузки DLL размытым

Это ловушка именно Windows. Если грузить DLL только по имени, как в LoadLibrary("foo.dll"), порядок поиска может подхватить DLL из непреднамеренного места.

Набор действий известен.

  • По возможности указывать абсолютный путь к DLL
  • Рано вызывать SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_DEFAULT_DIRS)
  • Явно добавлять каталоги поиска через AddDllDirectory
  • Не скармливать результат SearchPath напрямую в LoadLibrary
  • Не полагаться целиком на safe DLL search mode

Для native-кода сильная схема — вызвать это на ранней инициализации процесса.

SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);

Потом через AddDllDirectory регистрируете только нужные дополнительные каталоги.

«Обычно работает», поэтому это легко не трогать. Но если на месте развёртывания сменился рабочий каталог или в PATH попала DLL другого продукта, всё тихо ломается. Это важно не только для безопасности: так же хорошо предотвращает сбои.

Зафиксировать, откуда грузится DLLЗагрузка только по имени может подхватить чужую DLL из порядка поиска; рано вызывайте SetDefaultDllDirectories, явно добавляйте каталоги через AddDllDirectory и по возможности указывайте абсолютный путь.Грузить только по имениПорядок поиска может взять чужую DLLРано вызвать SetDefaultDllDirectoriesЯвно добавить через AddDllDirectoryПо возможности абсолютный путьИсточник загрузки больше не размыт

Рис. 16: Не грузите по одному имени: явно задайте поиск и зафиксируйте источник.

3.9. Не класть секреты в логи и исключения

Наращивать логи ради расследования сбоев важно. Но логи так же легко становятся свалкой секретов.

В логах на минимуме стоит пересмотреть следующее.

  • Не писать в лог пароли, Bearer token, API-ключи
  • Не писать строку подключения целиком
  • Маскировать персональные данные и тело бизнес-данных
  • Детали исключения разделять: экран пользователя и внутренний лог
  • Не включать в проде отладочные PII-логи
  • Пересмотреть права на каталоги dump и trace

В свежем .NET проще строить логирование с расчётом на redaction. Как минимум откажитесь от «всё в строку и сразу в лог».

Типичные промахи:

  • Сохранять тело HTTP request / response целиком
  • При ошибке аутентификации писать токен или все заголовки
  • Кидать текст исключения прямо в MessageBox
  • Класть все секретные логи в ZIP для обслуживания

Ошибки, например, разделяют так.

  • Пользователю: «Не удалось подключиться к серверу. Проверьте сетевые настройки и URL.»
  • Внутренний лог: хост, тип ошибки TLS, correlation ID, stack trace, число повторов

Уже одно это разделение заметно лучше балансирует утечку и расследование.

Как разделять показ ошибкиПользователю — короткое пояснение, во внутренний лог — хост, тип ошибки, correlation ID, stack trace и прочее для расследования.ОшибкаПользователю: только короткое пояснениеВнутренний лог: хост, тип, correlation ID и т.п.И утечку сдержать, и расследование сохранить

Рис. 17: Разделение экрана и внутреннего лога само по себе лучше балансирует утечку и расследование.

3.10. Не забрасывать библиотеки-зависимости и инструменты разработки

Последний пункт неброский, но эффект большой. Даже аккуратно написанное приложение стоит на дырявом фундаменте, если под ним старый runtime или зависимости с известными уязвимостями.

Смотреть нужно немногое.

  • Держать .NET SDK / runtime в поддерживаемых версиях
  • Регулярно проверять обновления NuGet / OSS
  • Для C++ вести версии распространяемого runtime и внешних DLL
  • Включать проверку уязвимостей в чек перед релизом
  • Держать smoke test, чтобы обновление зависимостей не ломало приложение незаметно

Здесь самое опасное — «потом сделаем пачкой». Полгода–год простоя, и дельта становится такой, что сама работа по безопасности превращается в тяжёлый проект.

Что бывает, если забросить обновление зависимостейЕсли откладывать обновление на полгода–год, дельта раздувается, и сама работа по безопасности становится тяжёлым проектом.этого можно избежатьПотом сделаем пачкойПолгода–год простояДельта становится слишком большойСама работа превращается в тяжёлый проектПроверять регулярно

Рис. 18: Чем дольше не трогать зависимости, тем больше дельта и тем тяжелее потом закрывать дыры.

3.11. Как проверять каждый пункт

Чек-лист работает, только если к нему приложен способ проверки. Ниже — то, что по пунктам 3.2–3.10 реально прогнать перед релизом.

Что проверить Как проверить
Не требуем ли повышение В манифесте приложения смотреть requestedExecutionLevel. В исходниках — app.manifest, в дистрибутиве — Sigcheck из Sysinternals или редактор ресурсов
Подпись и метка времени В PowerShell выполнить Get-AuthenticodeSignature .\app.exe и смотреть, что Status равен Valid и есть TimeStamperCertificate. То же по каждому комплектуемому DLL и updater
Не выключена ли проверка сертификата По всему коду искать ServerCertificateValidationCallback, DangerousAcceptAnyServerCertificateValidator, ServerCertificateCustomValidationCallback, CheckCertificateRevocationList
Секреты в коде Искать Password=, ApiKey, Secret, Token, ConnectionString. Не только текущие исходники, но и историю репозитория
Сборка SQL Искать конкатенацию со "SELECT, "INSERT, + и проверять, что путь идёт через Parameters.Add
Откуда грузится DLL В Process Monitor сузить на целевой процесс, фильтры Path ends with .dll и Result is NAME NOT FOUND. Видно, куда и в каком порядке ходили; проверьте, что нет лишних каталогов
Известные уязвимости зависимостей Выполнить dotnet list package --vulnerable --include-transitive
Не уходят ли секреты в лог Прогнать приложение и поискать в логах Bearer , Password, Authorization

Искать можно и rg (ripgrep), и поиском Visual Studio. Пакетно — так.

Get-ChildItem -Recurse -Include *.cs,*.vb,*.cpp,*.h,*.config,*.json |
    Select-String -Pattern 'ServerCertificateValidationCallback|DangerousAcceptAnyServerCertificateValidator|Password=|ApiKey' |
    Select-Object Path, LineNumber, Line

Главное — оставить запись о самой проверке. Если зафиксировать «искали, нашли 0», на следующем релизе достаточно смотреть дельту.

Зачем записывать саму проверкуДаже если перед релизом нашлось 0 совпадений, запись об этом позволяет на следующем релизе смотреть только дельту.Прогнать проверку перед релизомЗаписать даже «искали, 0 совпадений»На следующем релизе достаточно дельты

Рис. 19: Если зафиксировать факт проверки, дальше достаточно смотреть дельту.

4. Чек-лист перед релизом

Ниже — форма, которую можно сразу взять как шаблон ревью и решения о выпуске. Пункты, которые на минимуме стоит увидеть перед релизом, сгруппированы по категориям.

4.1. Права, модель выполнения

Пункт Отметка Примечание
Обычный запуск идёт как asInvoker  
Операции с правами администратора вынесены в отдельный EXE / службу и т.п.  
Если есть служба, учётная запись выполнения не сильнее необходимого  
Разведены зоны ответственности %ProgramFiles% и пользовательских данных  

4.2. Распространение, подпись

Пункт Отметка Примечание
EXE / DLL / MSI / MSIX / updater подписаны  
К подписи добавлена метка времени  
Срок сертификата и порядок продления входят в процесс релиза  
Есть способ проверить хеш дистрибутива и ловить подмену  

4.3. Обновление

Пункт Отметка Примечание
Обновления забираются по HTTPS  
После скачивания проверяется подпись или хеш  
Архитектура затрудняет произвольную подмену URL источника  
Есть политика отката или повторов при сбое обновления  

4.4. Секреты

Пункт Отметка Примечание
Пароли, API-ключи, строки подключения не прописаны в исходниках  
Секреты не лежат в plaintext-конфиге  
Секреты, которые нужно хранить локально, закрыты DPAPI / Credential Locker и т.п.  
Где можно, уходим на Windows-аутентификацию или учётные данные пользователя  

4.5. Сеть

Пункт Отметка Примечание
Прод идёт по HTTPS  
DangerousAcceptAnyServerCertificateValidator и => true не остались в поставке  
Учтены проверка отзыва и проверка имени хоста  
В прод не попал код или настройки под сертификат для разработки  

4.6. Ввод, доступ к данным

Пункт Отметка Примечание
SQL параметризован  
Для ввода из командной строки, файлов, IPC, URI и т.п. есть лимиты и проверка формата  
Операции с путями нормализуются, выход за корень не проходит  
Текст исключения не выводится на экран как есть  

4.7. DLL и среда выполнения

Пункт Отметка Примечание
Источник загрузки DLL задан явно  
Порядок поиска контролируется через SetDefaultDllDirectories / AddDllDirectory и т.п.  
Загрузка DLL не полагается на текущий каталог или PATH  
Известен набор файлов, нужных для динамической загрузки на месте развёртывания  

4.8. Логи, эксплуатация

Пункт Отметка Примечание
Токены, пароли, PII не уходят в лог  
Внутренний лог отделён от сообщений пользователю  
Права на каталоги dump / trace / log пересмотрены  
Проверяется актуальность SDK и библиотек-зависимостей  

5. Частые ошибки

На практике чаще всего встречаются примерно такие убеждения.

5.1. «Это внутренний инструмент, значит всё в порядке»

Даже во внутреннем инструменте обычное дело — битые файлы, ошибки оператора, принесённые устройства, общие папки, старые DLL, небрежные права. Отсутствие публикации в интернет поверхность атаки не убирает.

5.2. «HTTPS — значит безопасно»

HTTPS важен, но если отключить проверку сертификата, смысл сильно худеет. В раздаче обновлений нужен не только HTTPS, но и проверка подлинности дистрибутива.

5.3. «Зашифровали — значит безопасно»

Если не разведены место ключа, права на расшифровку, граница пользователя и граница машины, одного шифрования мало. Особенно путаница будет, если значение, защищённое LocalMachine, считать «секретом конкретного пользователя».

5.4. «Больше логов — легче расследовать»

Если логов просто много, а токены и персональные данные льются без разбора, это само становится инцидентом. Если нужна расследуемость, сначала решите, что оставлять, а что скрывать.

5.5. «Запустим от администратора — и всё решится»

Сначала удобно, потом обычно упираетесь в UAC, распространение, поддержку, границы прав, загрузку DLL и места хранения файлов. В долгую стабильнее минимальные права.

Чем кончается «запустим от администратора»Постоянная работа от администратора сначала удобна, потом ломается об UAC, распространение, поддержку, границы прав, загрузку DLL и каталоги; в долгую стабильнее минимальные права.в долгую стабильнееЗапустим от администратора — и всё решитсяСначала удобноПотом тяжело с UAC, распространением, поддержкойПотом тяжело с границами прав и каталогамиРаботать с минимальными правамиЭтой боли можно избежать

Рис. 20: Удобство «всегда администратор» короткое; в долгую стабильнее минимальные права.

6. Примерный порядок приоритетов

Если делать всё сразу тяжело, приоритеты обычно такие.

Критерий — масштаб ущерба при инциденте × дешевизна исправления. Глава 3 идёт по потоку проектирования «права → распространение → реализация → эксплуатация»; здесь порядок «сначала самое опасное», поэтому с главами он не совпадает. Рядом указаны соответствующие разделы.

  1. Пересмотр прав администратора (3.2) Сначала отказаться от постоянного requireAdministrator. Радиус ущерба меняется сразу, а проектное изменение часто небольшое.
  2. Подпись и метка времени (3.3) Навести доверие к дистрибутиву. Достаточно встроить в процедуру; если вставлять потом, понадобится повторная раздача.
  3. Убрать секреты (3.5) Вынести секреты из исходников и plaintext-конфига. Ущерб от утечки большой, после утечки уже не вернуть.
  4. Починить HTTPS и проверку сертификата (3.6) Убрать из поставки конструкции => true. Часто лечится удалением; если оставить, всей сети нельзя верить.
  5. Пересмотреть ввод SQL / файлов / IPC (3.7) Уменьшить конкатенацию и непроверенный ввод. Мест много, но чинится по одному.
  6. Зафиксировать загрузку DLL (3.8) Не грузить только по имени и не надеяться на PATH. Это правка раннего старта, плюс профилактика сбоев.
  7. Маскирование логов (3.9) Чтобы при инциденте лог не стал вторым ударом. Точек вывода много, времени уйдёт больше.
  8. Регулярное обновление зависимостей (3.10) Проверка на каждом релизе. Одним разом не закрывается: работа как раз в том, чтобы сделать это процессом.

Канала обновления (3.4) в этом списке нет, потому что автообновления нет у всех приложений. Если оно есть, смотрите его с тем же приоритетом, что пункт 2 про подпись. Модуль обновления слабее основного кода, но как раз он может этот код переписать.

Как расставлять приоритетыПорядок — произведение масштаба ущерба и дешевизны исправления, сначала самые опасные дыры; если есть автообновление, канал обновления смотрите с тем же приоритетом, что подпись.Масштаб ущербаПорядок = произведение двух осейДешевизна исправленияЗакрывать сначала самое опасноеЕсли есть автообновление, канал обновления — как подпись

Рис. 21: Приоритет — ущерб × дешевизна исправления; закрываем сначала самые опасные дыры.

В таком порядке проще двигаться в духе «сначала закрыть явно опасные дыры».

7. Итог

Безопасность разработки Windows-приложений заметно меняется ещё до специальных продуктов и больших систем, если привести в порядок семь пунктов: права, подпись, секреты, сеть, ввод, DLL, логи.

Минимальная планка по одной строке на пункт:

  • Не запускать всё приложение от администратора
  • Подписывать дистрибутив и обновления, ставить метку времени
  • Не класть секреты в исходники и plaintext-конфиг
  • Даже с HTTPS не отключать проверку сертификатов
  • Не доверять внешнему вводу: SQL, файлы, IPC и остальное
  • Не оставлять источник загрузки DLL размытым
  • Не класть секреты в лог
  • Не забрасывать библиотеки-зависимости

Тема безопасности широкая, и с самого начала делать всё не обязательно. Но минимум — не выпускать опасное поведение по умолчанию как есть — стоит закрыть довольно рано.

8. Источники

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Что смотреть в первую очередь в безопасности Windows-приложения?
Четыре пункта: не запрашивать лишние права администратора, подписывать код, не хранить секреты в открытом виде и не отключать проверку сертификатов. Минимальная безопасность — не про особые функции, а про то, чтобы не оставлять опасное поведение по умолчанию и небрежную реализацию. Даже на этой планке стоит избегать постоянного пропуска проверки сертификата, plaintext-строк подключения, загрузки DLL из текущего каталога и SQL через конкатенацию строк.
Нельзя ли просто запускать всё приложение с правами администратора?
Этого лучше не делать. Если всё приложение работает от администратора, баги, подмена DLL, неверное чтение конфигурации и дыры во внешнем вводе исполняются уже с этими высокими правами. Обычный UI-приложение держите на asInvoker, а операции, которым нужны права администратора, выносите в отдельный процесс или службу и повышайте права только в нужный момент.
Где хранить API-ключи и строки подключения?
Сначала уберите их из исходников и plaintext-конфигов вроде appsettings.json. Для секретов на диске в зависимости от задачи берите DPAPI / ProtectedData или Credential Locker. Если в лог уходят токены, пароли, строки подключения и персональные данные как есть, сам лог становится источником инцидента — такие поля нужно маскировать.
Нужна ли подпись кода, если приложение распространяется только внутри компании?
Да, это лучше считать обязательным. В Windows-приложениях поверхность атаки — сам дистрибутив (EXE / DLL / MSI / MSIX / модуль автообновления). Подпись кода плюс метка времени дают обнаружение подмены, доверие пользователей и понятное объяснение на эксплуатации. Канал обновления тоже должен фиксировать источник и ловить подмену через HTTPS и проверку подписи.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог