Lista de verificación mínima de seguridad para el desarrollo de aplicaciones de Windows

· Actualizado el: · · Desarrollo de Windows, Seguridad, Diseño, C# / .NET, Win32

Descargar la lista de verificación en formato Excel

El contenido de este archivo es el mismo que la lista de verificación previa al lanzamiento del capítulo 4 (8 categorías, 32 elementos). La única diferencia es que incluye campos para rellenar Status y Notes, y que tiene dos hojas, Checklist-ja y Checklist-en, con el japonés y el inglés. Basta con usar el capítulo 4 para revisar mientras se lee, y la versión Excel para distribuirla como registro de revisión.

Cuando se habla de seguridad en aplicaciones de Windows, el alcance tiende a crecer rápidamente. Confianza cero, EDR, SBOM (Software Bill of Materials, lista de materiales de software), gestión de certificados, gestión de vulnerabilidades. Todo esto es importante, pero en la práctica hay muchos aspectos básicos que conviene no descuidar antes de llegar ahí.

En particular, en aplicaciones como las siguientes, cerrar las brechas básicas rinde más que aplicar “defensas avanzadas” desde el principio.

  • Aplicaciones de escritorio WPF / WinForms / WinUI
  • Aplicaciones Win32 en C++ / C#
  • Integración con equipos, integración de archivos, conexión a bases de datos, herramientas de distribución interna
  • Aplicaciones empresariales con mecanismo de actualización automática
  • Configuraciones que incluyen servicios de Windows o EXE auxiliares

En el desarrollo de aplicaciones de Windows, es más realista empezar por no dejar agujeros claramente peligrosos que intentar perfeccionarlo todo de una vez. Aquí se organizan, en el orden de diseño, implementación, distribución y operación, los puntos mínimos que conviene no pasar por alto, de forma fácil de verificar.

1. Primero, la conclusión

  • Lo primero que no conviene descuidar es no solicitar privilegios de administrador innecesarios, firmar el código, no mantener información confidencial en texto plano y no deshabilitar la verificación de certificados.
  • En las aplicaciones de Windows, el propio material distribuido es la superficie de ataque. Es más seguro considerar también EXE / DLL / MSI / MSIX / el módulo de actualización automática.
  • ServerCertificateValidationCallback => true, las cadenas de conexión en texto plano, la carga descuidada mediante LoadLibrary("foo.dll") y la ejecución de SQL por concatenación de cadenas son puntos que conviene evitar incluso en el nivel mínimo.
  • Si solo una parte del procesamiento requiere privilegios de administrador, es más seguro no elevar toda la aplicación, sino separar únicamente esa parte en un EXE o servicio distinto.
  • Para las aplicaciones distribuidas en Windows conviene partir de la base de firma + marca de tiempo. Esto no solo aporta confianza a los usuarios, sino que también facilita la detección de manipulaciones y las explicaciones operativas.
  • La información confidencial almacenada debe protegerse alternando, según el uso, entre DPAPI / ProtectedData y Credential Locker. Como mínimo, conviene abandonar la práctica de dejarla en texto plano en appsettings.json.
  • Los registros no son mejores cuanto más abundantes. Dejar tokens, contraseñas, cadenas de conexión, información personal o el cuerpo completo de las solicitudes tal cual convierte al propio registro en el protagonista del incidente.

La seguridad mínima consiste menos en añadir funciones especiales que en no dejar comportamientos predeterminados peligrosos ni implementaciones descuidadas.

2. Alcance de este artículo y qué significa “mínimo”

2.1. Alcance considerado

Este artículo está pensado para aplicaciones de Windows como las siguientes.

  • Aplicaciones de escritorio WPF / WinForms / WinUI
  • Aplicaciones Win32 en C++ / C#
  • Herramientas de distribución interna, herramientas de integración con equipos, herramientas de monitorización
  • Configuraciones que incluyen EXE auxiliares, servicios de Windows y actualizadores
  • Software empresarial distribuido como EXE / MSI / MSIX

Aquí, “mínimo” no significa la forma final que supera una auditoría, sino los elementos cuya ausencia provoca incidentes con normalidad.

También conviene alinear las premisas de los ejemplos de código. Los ejemplos en C# suponen .NET 8 o posterior, y los de C++ suponen la API Win32. En .NET Framework 4.8 la idea general se mantiene, pero hay puntos donde la forma de escribir recomendada ha cambiado. Un ejemplo representativo es lo relacionado con ServicePointManager en 3.6, donde el código nuevo parte de usar IHttpClientFactory y HttpClient. Al revisar código de generaciones anteriores, tenga esto en cuenta.

2.2. Fuera de alcance

Por otro lado, hay temas que quedan fuera del núcleo de este artículo.

  • El diseño de confianza cero de toda la empresa
  • La operación integral de EDR / SIEM / DLP / MDM
  • El endurecimiento detallado de controladores en modo kernel
  • El diseño criptográfico desde cero
  • Los procedimientos avanzados de análisis de amenazas o forense

Es decir, no se trata de “las grandes iniciativas de seguridad de toda la organización”, sino de la línea base que los desarrolladores de aplicaciones de Windows difícilmente pueden dejar de cubrir por sí mismos antes del lanzamiento.

3. La lista de verificación que conviene mirar primero

Antes de entrar en los detalles, primero se presenta una tabla que permite ver el conjunto. Solo con esto ya se puede tener una idea de dónde revisar.

3.1. Panorama general

Elemento a verificar Lo mínimo que hay que hacer NG típico
Privilegios de ejecución Usar asInvoker como predeterminado y separar solo el procesamiento que requiere elevación Configurar toda la aplicación como requireAdministrator
Confiabilidad del material distribuido Firmar EXE / DLL / MSI / MSIX con firma de código, y añadir también la marca de tiempo Distribuir sin firmar
Actualizaciones Fijar el origen de las actualizaciones y detectar manipulaciones mediante HTTPS y verificación de firma Sobrescribir directamente tras una descarga por HTTP
Información confidencial No colocar secretos en el código fuente ni en configuración en texto plano; usar DPAPI / Credential Locker, etc. Colocar claves de API o cadenas de conexión en texto plano en archivos de configuración
Comunicaciones Usar HTTPS y no deshabilitar la verificación de certificados Omitir siempre la verificación de certificados con return true
Entradas externas Validar por completo SQL, archivos, IPC, URI, CSV, JSON, etc. Dejarlas pasar sin más porque “es una herramienta interna”
Carga de DLL Usar rutas absolutas, SetDefaultDllDirectories y un orden de búsqueda seguro Dejar LoadLibrary("foo.dll") a merced del directorio actual
Registros Enmascarar tokens, contraseñas y PII, y diferenciar los mensajes de error para el usuario Mostrar y guardar tal cual los detalles de excepciones o las cadenas de conexión
Dependencias Actualizar de forma continua el SDK, NuGet, el runtime de VC++ y las dependencias OSS Fijarlas durante años sin seguir la información de vulnerabilidades

3.2. Los privilegios se basan en asInvoker

Esto es lo primero que conviene revisar en una aplicación de Windows. Si toda la aplicación se ejecuta con privilegios de administrador, los errores, la sustitución de DLL, la lectura incorrecta de archivos de configuración y las deficiencias en las entradas externas se ejecutan directamente con privilegios elevados.

La política básica es la siguiente.

  • Las aplicaciones de UI habituales usan asInvoker
  • Solo el procesamiento que requiere privilegios de administrador se separa en un proceso o servicio distinto
  • La elevación se realiza únicamente en el momento necesario
  • También se valida la entrada que se pasa al EXE auxiliar o al servicio

Si se trata de una aplicación de escritorio que normalmente solo se usa para consultar y editar, y solo la instalación o el cambio de configuración del firewall requieren privilegios de administrador, es más seguro desplazar solo la parte que requiere elevación a un broker, en lugar de configurar toda la aplicación como requireAdministrator.

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

“Es más cómodo si funciona como administrador” suele pasar factura más adelante. Ejecutar con el mínimo privilegio posible y aislar solo las operaciones que realmente lo requieren reduce considerablemente el radio del incidente.

3.3. Firmar los binarios y el instalador

En Windows, la confiabilidad del material distribuido es determinante. Lo que el usuario toca no es el código fuente, sino el EXE, el DLL, el MSI, el MSIX y el actualizador. Si esto no está firmado, tanto la explicación operativa como la detección de manipulaciones y la sensación de seguridad al distribuir se debilitan.

Como mínimo, conviene revisar lo siguiente.

  • Firmar EXE / DLL / MSI / MSIX
  • Firmar no solo el instalador, sino también los binarios auxiliares usados para actualizar
  • Añadir marca de tiempo
  • Incluir la fecha de caducidad del certificado y el procedimiento de renovación en el proceso de lanzamiento

En particular, una firma sin marca de tiempo suele causar problemas al verificarla después de que caduque el certificado. En lugar de pensar “ya está firmado, con eso basta”, es más estable incluir firma + marca de tiempo en el procedimiento de lanzamiento.

Si se usa MSIX, la firma del paquete es un requisito previo. En la distribución con MSI / EXE, también conviene firmar al menos el propio instalador y los binarios ejecutables principales.

3.4. Fijar la ruta de actualización e introducir detección de manipulaciones

En las aplicaciones de Windows actuales, la ruta de actualización se usa durante más tiempo que la instalación inicial. Si esto se descuida, aunque el cuerpo principal se haya construido con cuidado, el actualizador se convierte en el eslabón más débil.

En torno a las actualizaciones, conviene tener en cuenta al menos estos 5 puntos.

  • La obtención de los archivos de actualización parte de HTTPS
  • Verificar la firma o el hash del material actualizado descargado
  • Evitar que la URL de origen de las actualizaciones pueda sustituirse sin límite desde el código o la configuración
  • Firmar también el propio módulo de actualización
  • Definir el procedimiento de reversión (rollback) o de recuperación en caso de fallo

Si se puede optar por MSIX + App Installer, es más fácil delegar el mecanismo de actualización en el sistema operativo. Por otro lado, si se cuenta con un actualizador propio, es necesario confirmar tanto la seguridad de la comunicación como la autenticidad del material distribuido. Con HTTPS solo se protege la “ruta de comunicación”, pero no se garantiza que “ese archivo sea realmente su propia publicación”.

3.5. No colocar información confidencial en el código fuente ni en configuración en texto plano

Este es un punto donde en la práctica realmente ocurren incidentes. Por pensar “es una herramienta interna” o “de todos modos solo se distribuye el exe”, se suelen colocar cadenas de conexión, claves de API, credenciales de carpetas compartidas o tokens fijos en el código fuente o en archivos de configuración.

Como mínimo, conviene evitar estas formas de almacenamiento.

  • Claves de API escritas directamente en el código fuente
  • Contraseñas en texto plano en appsettings.json o app.config
  • Cadenas de conexión incluidas en el repositorio
  • Un diseño que coloca la clave de descifrado y el texto cifrado en el mismo lugar
  • Credenciales fijas comunes para todos los usuarios en lugar de credenciales por usuario

En una aplicación de Windows, las opciones realistas se reducen básicamente a estas 4.

  • Desea guardar credenciales de Windows Si es una packaged desktop app o de la familia WinUI, considere Credential Locker
  • Desea cifrar y guardar secretos de forma local En Win32 / .NET, use DPAPI / ProtectedData
  • El destino de conexión puede usar autenticación de Windows o autenticación integrada Si es posible, evite que la aplicación tenga que manejar contraseñas
  • Puede gestionar los secretos en la nube o en el servidor Priorice un diseño que no incruste secretos de larga duración en el cliente

En C#, aunque solo sea usando DPAPI de la siguiente manera, la mejora respecto al almacenamiento en texto plano ya es considerable. Si se escribe el ciclo completo de guardado y lectura, queda así.

// C# / .NET 8. ProtectedData es exclusivo de Windows, y en .NET
// requiere el paquete NuGet System.Security.Cryptography.ProtectedData.
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;

public static class SecretStore
{
    // Se necesita el mismo valor al descifrar. Funciona incluso con null, pero es más seguro definirlo.
    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);

        // Se puede escribir la secuencia de bytes tal cual en el archivo, pero si se coloca en un archivo de configuración conviene usar Base64.
        File.WriteAllText(path, Convert.ToBase64String(ciphertext));

        CryptographicOperations.ZeroMemory(plaintext);
    }

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

        // Si no coincide con el mismo usuario y el mismo entropy usados al guardar, se produce CryptographicException.
        byte[] plaintext = ProtectedData.Unprotect(
            ciphertext,
            Entropy,
            DataProtectionScope.CurrentUser);

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

El lado que la invoca queda así.

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);

Lo importante aquí no es “está cifrado, así que es seguro”, sino decidir mediante el diseño quién puede descifrarlo. El significado cambia bastante según se use CurrentUser o LocalMachine. Con CurrentUser solo puede descifrar el usuario que guardó el dato, mientras que con LocalMachine puede descifrarlo cualquiera de la misma máquina. Si se ejecuta como servicio con una cuenta distinta, o si hay una operación de cambio de usuario, es necesario decidir esto de antemano; de lo contrario, más adelante se topará con “no se puede leer” o, al contrario, con “se puede leer de más”.

Además, dado que DPAPI guarda la clave en el perfil del usuario, la documentación indica explícitamente que el descifrado puede fallar cuando el perfil no está cargado (por ejemplo, durante una suplantación/impersonation). Si se plantea usarlo desde un servicio, conviene confirmar también este punto.

Para conexiones a SQL Server, en entornos on-premises a veces se puede tomar la autenticación de Windows como primera opción. Si de todos modos es necesario incluir credenciales en la cadena de conexión, es más seguro al menos mantener Persist Security Info=False y no dejarlas en un archivo de configuración en texto plano.

3.6. La comunicación parte de HTTPS, sin matar la verificación de certificados

Un atajo introducido “solo para el desarrollo” que termina quedándose tal cual en producción. Los incidentes relacionados con las comunicaciones suelen seguir este patrón.

En particular, este tipo de código o configuración tiende a quedar en el producto final.

  • ServicePointManager.ServerCertificateValidationCallback += ... => true
  • HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
  • Enviar el producto con la verificación de revocación de certificados deshabilitada
  • Dejar en producción código que asume un certificado autofirmado de desarrollo

La política mínima es simple.

  • Las comunicaciones en producción usan HTTPS
  • No omitir siempre la verificación de certificados
  • Si excepcionalmente se necesita relajar la verificación, limitarla al host y al certificado en cuestión
  • Excluir con seguridad el código de bypass de desarrollo mediante condiciones de compilación o configuración
  • En .NET, tener presente también la verificación de revocación

Un ejemplo de lo que no debe hacerse es, básicamente, así.

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

A simple vista parece cómodo, pero esto se comporta casi como “esta comunicación HTTPS se acepta con cualquiera con quien se conecte”. Si se elimina la verificación de certificados, aunque se use HTTPS, el contenido queda bastante desprotegido.

La forma de escribirlo cambia según qué versión de .NET se asuma

Colocar la configuración global en ServicePointManager es una forma de escribir propia de la época de .NET Framework. En código nuevo, es más natural recibir el HttpClient desde IHttpClientFactory y, si se necesita configuración relacionada con TLS, colocarla en SocketsHttpHandler o en HttpClientHandler.

Sin embargo, dejarlo así pensando “es una API antigua, ya no debe tener efecto” es peligroso. La documentación de Microsoft indica que ServicePointManager.ServerCertificateValidationCallback se asigna, a partir de .NET 9, al RemoteCertificateValidationCallback de SocketsHttpHandler.SslOptions. Es decir, una sola línea con => true en algún lugar puede terminar afectando también a las comunicaciones de HttpClient.

Cuando excepcionalmente se desee relajar la verificación, en lugar de una configuración global que afecta a todo el proceso, conviene limitarla exclusivamente a ese handler.

// C# / .NET 8. Ejemplo que trata como excepción solo un host y un certificado concretos.
// Aunque se relaje para desarrollo, si no se limita el objetivo, es como matarla globalmente.
using System;
using System.Linq;
using System.Net.Http;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;

// Huella digital del certificado en cuestión. También se puede leer desde la configuración.
const string ExpectedThumbprint = "2B0C4E6A8D1F3B5D7F9A1C3E5A7C9E1B3D5F7A91";

var handler = new HttpClientHandler
{
    CheckCertificateRevocationList = true,   // Comprueba la revocación. El valor predeterminado es false
    ServerCertificateCustomValidationCallback = (request, certificate, chain, errors) =>
    {
        if (errors == SslPolicyErrors.None)
        {
            return true;
        }

        // Solo se admite un único caso: "este equipo no confía en la CA interna".
        // No se admite que el certificado no llegue o que el nombre de host no coincida
        if (errors != SslPolicyErrors.RemoteCertificateChainErrors)
        {
            return false;
        }

        // Se fija tanto el destino como el certificado
        if (request.RequestUri?.Host != "device.internal.example"
            || certificate is null
            || !string.Equals(certificate.Thumbprint, ExpectedThumbprint,
                              StringComparison.OrdinalIgnoreCase))
        {
            return false;
        }

        // Ni siquiera el único certificado anclado (pinned) se acepta si está caducado o revocado.
        // Aceptarlo abriría la puerta a seguir usando "un certificado revocado por fuga de la clave"
        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 se proporciona como una constante o desde la configuración, con la huella digital del certificado en cuestión.

Lo importante aquí es no “hacer como que no se ven” errors y chain.ChainStatus. Si se devuelve true solo porque la huella digital coincide, se sigue aceptando ese certificado incluso si ha caducado, o incluso después de que se haya revocado por una fuga de la clave. El anclaje (pinning) significa “confiar solo en este certificado”, no “confiar en este certificado pase lo que pase”. En el código anterior, lo único que se admite es UntrustedRoot y PartialChain (es decir, que la CA interna no esté instalada en este equipo); NotTimeValid (caducado) o Revoked (revocado) se rechazan directamente.

Para comprobar la revocación se necesita CheckCertificateRevocationList = true (el valor predeterminado es false, y la revocación no se verifica). A la inversa, si la CA interna no publica ni CRL ni OCSP, se rechazará con RevocationStatusUnknown. Ese es el comportamiento correcto. Si no se dispone de un medio para verificar la revocación, cúbralo mediante un periodo de validez del certificado más corto o una vía preparada de antemano para redistribuir el valor anclado. Lo más peligroso es “anclar un certificado de larga duración sin poder revocarlo nunca”.

3.7. Tratar toda entrada externa como “entrada no confiable”

Como las aplicaciones de Windows no son aplicaciones web, la validación de entradas tiende a ser laxa. Pero en la práctica, los puntos de entrada externos son más numerosos de lo que se piensa.

  • Rutas de archivo
  • CSV / Excel / JSON / XML
  • Argumentos de línea de comandos
  • named pipe / socket / COM / RPC / gRPC
  • Cadenas que se pasan a la base de datos
  • Valores del registro
  • Portapapeles
  • URL / deep link
  • Datos devueltos por equipos externos o SDK

En particular, hay 3 puntos que no conviene descuidar en el nivel mínimo.

  1. El SQL siempre debe parametrizarse No construir el SQL por concatenación de cadenas.
  2. Las rutas de archivo deben normalizarse antes de usarse No usar directamente una ruta indicada por el usuario para eliminar, sobrescribir o expandir.
  3. La lectura de archivos externos debe incluir un límite de tamaño y una comprobación de formato “Se pudo abrir” no equivale a “es seguro”.

En cuanto al ejemplo de SQL, esto es lo que se quiere evitar.

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

Como mínimo, conviene acercarse a esto.

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;

Suponer que “las entradas son confiables porque es una herramienta interna” es una premisa bastante peligrosa. En la realidad, llegan con normalidad CSV corruptos, nombres de archivo inesperados, datos antiguos de la base de datos, errores de entrada manual del operador o JSON a medio construir escrito por otra herramienta.

3.8. No dejar ambiguo el origen de carga de los DLL

Esta es una trampa muy propia de Windows. Si se carga un DLL solo por su nombre, como en LoadLibrary("foo.dll"), según el orden de búsqueda puede terminar cargándose un DLL de una ubicación no prevista.

Lo que hay que hacer está bien definido.

  • Cuando sea posible, especificar la ruta absoluta del DLL
  • Configurar SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_DEFAULT_DIRS) en una etapa temprana
  • Añadir explícitamente los objetivos de búsqueda con AddDllDirectory
  • Evitar diseños que pasen el resultado de SearchPath directamente a LoadLibrary
  • No depender por completo del safe DLL search mode

Por ejemplo, en código nativo, un diseño sólido consiste en incluir lo siguiente en una etapa temprana de la inicialización del proceso.

SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);

Y luego registrar con AddDllDirectory únicamente los directorios adicionales necesarios.

Como esto “normalmente funciona”, tiende a quedar descuidado, pero si el directorio de trabajo cambia en el sitio de distribución, o si hay un DLL de otro producto en el PATH, se rompe de forma silenciosa. No solo aporta seguridad; también resulta bastante eficaz para prevenir fallos.

3.9. No exponer secretos en los registros ni en las excepciones

Aumentar los registros para investigar incidencias es importante. Pero los registros también tienden a convertirse en el cementerio de los secretos.

En torno a los registros, conviene revisar al menos lo siguiente.

  • No sacar contraseñas, Bearer token ni claves de API en los registros
  • No mostrar la cadena de conexión completa
  • Enmascarar la información personal y el cuerpo de los datos de negocio
  • Separar los detalles de la excepción entre la pantalla para el usuario y el registro interno
  • No habilitar en producción registros de PII pensados para depuración
  • Revisar los permisos del destino de guardado de dumps y trazas

En las versiones recientes de .NET también resulta más fácil organizar todo esto partiendo de la redacción (redaction). Como mínimo, conviene dejar de “convertir cualquier cosa a texto y registrarla tal cual”.

Algunos fallos habituales son estos.

  • Guardar el cuerpo completo de la solicitud/respuesta HTTP
  • Sacar el token o las cabeceras completas cuando falla la autenticación
  • Mostrar el mensaje de excepción tal cual en un MessageBox
  • Incluir todos los registros confidenciales en el ZIP de mantenimiento

La presentación de errores se separa, por ejemplo, así.

  • Para el usuario: “No se pudo conectar con el servidor. Compruebe la configuración de red y la URL.”
  • Registro interno: host de destino del fallo, tipo de error TLS, ID de correlación, stack trace, número de reintentos

Solo con esta separación, el equilibrio entre la fuga de información y la capacidad de investigación mejora considerablemente.

3.10. No dejar de lado las bibliotecas dependientes ni las herramientas de desarrollo

El último punto es discreto, pero de gran impacto. Aunque el cuerpo de la aplicación se construya con esmero, si se mantienen runtimes antiguos o bibliotecas dependientes con vulnerabilidades conocidas, el terreno se debilita.

Los elementos a revisar en sí no son muchos.

  • Mantener el SDK / runtime de .NET en una versión con soporte
  • Confirmar periódicamente las actualizaciones de dependencias de NuGet / OSS
  • En C++, gestionar las versiones de los redistribuibles del runtime y de los DLL externos
  • Incluir la confirmación de información de vulnerabilidades en la verificación previa al lanzamiento
  • Preparar pruebas de humo (smoke test) para no romperse con las actualizaciones de dependencias

Aquí, “lo hago todo junto más tarde” es lo más peligroso. Si se descuida durante medio año o un año, la diferencia de actualización se vuelve demasiado grande, y la propia respuesta de seguridad termina convirtiéndose en un trabajo pesado.

3.11. Cómo verificar cada elemento

Una lista de verificación solo funciona junto con su método de comprobación. Para los elementos de 3.2 a 3.10, se enumeran a continuación comprobaciones que se pueden ejecutar realmente antes del lanzamiento.

Qué se quiere confirmar Método de confirmación
Si se solicita elevación Ver el valor de requestedExecutionLevel en el manifiesto de la aplicación. En el código fuente es app.manifest; en el material distribuido, confirmarlo con Sigcheck de Sysinternals o con un editor de recursos
Firma y marca de tiempo Ejecutar Get-AuthenticodeSignature .\app.exe en PowerShell y comprobar si Status es Valid y si TimeStamperCertificate está presente. Revisar también, uno por uno, los DLL incluidos y el updater
Si se ha matado la verificación de certificados Buscar en todo el código fuente ServerCertificateValidationCallback, DangerousAcceptAnyServerCertificateValidator, ServerCertificateCustomValidationCallback y CheckCertificateRevocationList
Información confidencial escrita directamente Buscar Password=, ApiKey, Secret, Token, ConnectionString. Incluir no solo el código fuente actual, sino también el historial del repositorio
Construcción del SQL Buscar concatenaciones de cadenas que contengan "SELECT, "INSERT, + , y confirmar si pasan por Parameters.Add
Origen de carga de los DLL Con Process Monitor, filtrar por el proceso en cuestión y aplicar los filtros Path ends with .dll y Result is NAME NOT FOUND. Como se ve dónde y en qué orden se buscó, se puede confirmar si se está mirando alguna carpeta no prevista
Vulnerabilidades conocidas en las dependencias Ejecutar dotnet list package --vulnerable --include-transitive
Si hay secretos expuestos en los registros Tras ejecutar la aplicación una vez, buscar en los registros generados Bearer , Password y Authorization

La búsqueda en el código puede hacerse tanto con rg (ripgrep) como con la búsqueda de Visual Studio. Para ejecutarlo todo de una vez, esta es la forma.

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

Lo importante es dejar registro de que se hizo la comprobación. Si se deja constancia de que “se buscó” y de que “hubo 0 resultados”, en el siguiente lanzamiento bastará con revisar solo la diferencia.

4. Lista de verificación previa al lanzamiento

Se presenta en un formato listo para usar como plantilla de revisión o de decisión de envío. Para que sea fácil de confirmar en tabla, los elementos mínimos que conviene revisar antes del lanzamiento se agrupan por categoría.

4.1. Privilegios, forma de ejecución

Elemento a verificar Confirmación Notas
El inicio normal se ejecuta con asInvoker  
El procesamiento que requiere privilegios de administrador está separado en un EXE / servicio distinto, etc.  
Si se usa un servicio, no se ha configurado con una cuenta de ejecución más privilegiada de lo necesario  
Las responsabilidades bajo %ProgramFiles% y bajo los datos de usuario están separadas  

4.2. Distribución, firma

Elemento a verificar Confirmación Notas
Se firma el EXE / DLL / MSI / MSIX / updater  
La firma incluye marca de tiempo  
La fecha de caducidad del certificado y el procedimiento de renovación están incluidos en el flujo de lanzamiento  
Está definido el método de verificación de hash o de detección de manipulaciones del material distribuido  

4.3. Actualizaciones

Elemento a verificar Confirmación Notas
La obtención de actualizaciones se realiza por HTTPS  
Se verifica la firma o el hash tras la descarga  
El diseño impide que la URL de origen de las actualizaciones se sustituya con facilidad  
Existe una política de reversión o de reintento en caso de fallo de actualización  

4.4. Información confidencial

Elemento a verificar Confirmación Notas
No se escriben directamente en el código fuente contraseñas, claves de API ni cadenas de conexión  
No se colocan secretos en archivos de configuración en texto plano  
Los secretos que deben guardarse localmente están protegidos con DPAPI / Credential Locker, etc.  
Donde es posible, se recurre a la autenticación de Windows o a las credenciales del usuario  

4.5. Comunicaciones

Elemento a verificar Confirmación Notas
Las comunicaciones en producción usan HTTPS  
No se deja en el producto final DangerousAcceptAnyServerCertificateValidator ni => true  
Se tiene en cuenta la verificación de revocación y la validación del nombre de host  
No hay mezclado en producción código o configuración que asuma certificados de desarrollo  

4.6. Entradas, acceso a datos

Elemento a verificar Confirmación Notas
El SQL está parametrizado  
Las entradas de línea de comandos, archivos, IPC, URI, etc. tienen límite y comprobación de formato  
Las operaciones de rutas se normalizan para evitar la salida de la raíz  
No se muestra el mensaje de excepción tal cual en pantalla  

4.7. DLL y entorno de ejecución

Elemento a verificar Confirmación Notas
El origen de carga de los DLL está indicado de forma explícita  
El orden de búsqueda se controla con SetDefaultDllDirectories / AddDllDirectory, etc.  
No se deja la carga de DLL a merced del directorio actual o del PATH  
Se conoce el conjunto de archivos necesarios para la carga dinámica en el sitio de distribución  

4.8. Registros, operación

Elemento a verificar Confirmación Notas
No se sacan tokens, contraseñas ni PII en los registros  
Se separan el registro interno y los mensajes dirigidos al usuario  
Se han revisado los permisos del destino de guardado de dump / trace / log  
Se confirma el estado de actualización del SDK y de las bibliotecas dependientes  

5. NG habituales

En la práctica, lo que suele verse son más o menos estas ideas preconcebidas.

5.1. “Como es una herramienta interna, no hay problema”

Incluso en una herramienta interna hay con normalidad archivos corruptos, errores de operación, equipos personales, carpetas compartidas, DLL antiguos y configuraciones de permisos descuidadas. Aunque no esté publicada en internet, la superficie de ataque no desaparece.

5.2. “Como es HTTPS, es seguro”

HTTPS es importante, pero si se deshabilita la verificación de certificados, su sentido se debilita considerablemente. Además, en la distribución de actualizaciones no basta con HTTPS: también es necesaria la verificación de autenticidad del material distribuido.

5.3. “Como está cifrado, es seguro”

Si no están organizados el lugar donde se guarda la clave de descifrado, quién tiene permiso de descifrado, y los límites de usuario y de máquina, el cifrado por sí solo no basta. En particular, si se usa un valor protegido con LocalMachine pensando que es “un secreto por usuario”, más adelante se producirá confusión.

5.4. “Con más registros se puede investigar”

Si los registros son abundantes pero dejan fluir tokens o información personal sin control, eso en sí mismo se convierte en un incidente. Si se quiere capacidad de investigación, lo primero es decidir qué se conserva y qué se oculta.

5.5. “Ejecutar como administrador lo soluciona”

Al principio resulta cómodo, pero después suele complicarse con UAC, la distribución, el soporte, los límites de privilegios, la carga de DLL y el destino de guardado de archivos. El mínimo privilegio es más estable a largo plazo.

6. Orden de prioridad general

Si hacerlo todo de golpe resulta demasiado pesado, el orden de prioridad es, en líneas generales, el siguiente.

El criterio de ordenación es la combinación entre la magnitud del daño si ocurre un incidente y el bajo coste de corregirlo. El capítulo 3 se ordena siguiendo el flujo de diseño “privilegios → distribución → implementación → operación”, pero aquí el orden es “de lo más peligroso a lo menos”, por lo que no coincide con el orden de los capítulos. Se indica el apartado correspondiente en cada punto.

  1. Revisión de los privilegios de administrador (3.2) Primero, dejar de usar requireAdministrator de forma habitual. El alcance del daño cambia de nivel, mientras que como cambio de diseño suele ser algo pequeño.
  2. Firma y marca de tiempo (3.3) Ordenar la confiabilidad del material distribuido. Basta con incorporarlo al procedimiento, y si se añade más tarde requiere volver a redistribuir.
  3. Retirada de la información confidencial (3.5) Sacar los secretos del código fuente y de la configuración en texto plano. El daño si se filtran es grande, y una vez filtrados no hay vuelta atrás.
  4. Corrección de HTTPS + verificación de certificados (3.6) Eliminar del producto final los patrones tipo => true. A menudo basta con eliminarlos para corregirlo, y si se descuida, toda la comunicación deja de ser confiable.
  5. Revisión de las entradas SQL / archivos / IPC (3.7) Reducir la concatenación de cadenas y las entradas sin validar. Son numerosas, pero se pueden corregir de una en una.
  6. Fijación de la carga de DLL (3.8) Dejar de cargar solo por nombre y de depender del PATH. Es un trabajo de corregir una parte del procesamiento de inicio, y también sirve para prevenir fallos.
  7. Enmascaramiento de los registros (3.9) Evitar que los registros se conviertan en un daño secundario en caso de incidente. Como hay muchos puntos de salida, lleva tiempo.
  8. Regularización de la actualización de dependencias (3.10) Convertirlo en un flujo que se confirma en cada lanzamiento. No termina de una sola vez, así que el verdadero trabajo es convertirlo en un sistema.

La razón por la que la ruta de actualización (3.4) no aparece en este orden es que hay aplicaciones que no tienen actualización automática. Si la tiene, trátela con la misma prioridad que el punto 2, la firma. El módulo de actualización está en una posición más débil que el cuerpo principal, pero con capacidad de reescribirlo.

Con este orden resulta más fácil avanzar en el sentido de “primero cerrar los agujeros claramente peligrosos”.

7. Resumen

La seguridad en el desarrollo de aplicaciones de Windows cambia bastante solo con ordenar estos 7 puntos — privilegios, firma, información confidencial, comunicaciones, entradas, DLL y registros — antes de introducir productos especiales o mecanismos enormes.

Resumiendo la línea mínima en una frase cada punto, queda así.

  • No ejecutar toda la aplicación con privilegios de administrador
  • Firmar el material distribuido y las actualizaciones, y añadir marca de tiempo
  • No colocar información confidencial en el código fuente ni en configuración en texto plano
  • No matar la verificación de certificados aunque se use HTTPS
  • No confiar en las entradas externas como SQL, archivos, IPC, etc.
  • No dejar ambiguo el origen de carga de los DLL
  • No exponer secretos en los registros
  • No dejar de lado las bibliotecas dependientes

El tema de la seguridad es amplio, pero no hace falta abordarlo todo desde el principio. Sin embargo, el mínimo de no distribuir con comportamientos predeterminados peligrosos merece cubrirse en una etapa bastante temprana.

8. Referencias

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

El artículo está directamente relacionado con los siguientes servicios.

Preguntas frecuentes

Preguntas habituales en las consultas sobre el tema del artículo.

¿Qué es lo primero que hay que revisar en la seguridad de una aplicación de Windows?
Son estos cuatro puntos: no solicitar privilegios de administrador innecesarios, firmar el código, no mantener información confidencial en texto plano y no deshabilitar la verificación de certificados. La seguridad mínima consiste menos en añadir funciones especiales que en no dejar comportamientos predeterminados peligrosos ni implementaciones descuidadas. Omitir siempre la verificación de certificados, usar cadenas de conexión en texto plano, cargar DLL confiando en el directorio actual y ejecutar SQL mediante concatenación de cadenas son puntos que conviene evitar incluso en el nivel mínimo.
¿No se debe ejecutar toda la aplicación con privilegios de administrador?
Debe evitarse. Si toda la aplicación se ejecuta con privilegios de administrador, los errores, la sustitución de DLL, la lectura incorrecta de archivos de configuración y las deficiencias en las entradas externas se ejecutan directamente con privilegios elevados. La política básica consiste en usar asInvoker como predeterminado para las aplicaciones de UI habituales, separar en un proceso o servicio distinto solo el procesamiento que requiere privilegios de administrador, y elevar los privilegios únicamente en el momento necesario.
¿Dónde se deben almacenar las claves de API o las cadenas de conexión?
Lo primero es dejar de guardarlas en texto plano en el código fuente o en archivos de configuración como appsettings.json. Para la información confidencial almacenada, conviene alternar entre DPAPI / ProtectedData y Credential Locker según el uso. Además, si se dejan tokens, contraseñas, cadenas de conexión o información personal tal cual en los registros, el propio registro se convierte en el protagonista del incidente, por lo que es necesario enmascararlos.
¿Es necesaria la firma de código incluso en aplicaciones de distribución interna?
Conviene considerarla un requisito de partida. En las aplicaciones de Windows, el propio material distribuido (EXE / DLL / MSI / MSIX / módulo de actualización automática) es la superficie de ataque. Contar con firma de código + marca de tiempo aporta detección de manipulaciones, confianza para los usuarios y facilidad para explicar la operación. El mecanismo de actualización también debe fijar el origen de las actualizaciones y detectar manipulaciones mediante HTTPS y verificación de firma.

Perfil del autor

Página de presentación del autor del artículo.

Go Komura

Representante de KomuraSoft LLC

Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.

Volver al blog