Por qué se rompen los argumentos — Las reglas de los argumentos de línea de comandos de Windows

· Actualizado el: · · Windows, Desarrollo Windows, C#, C++, Win32 API, .NET, Proceso

Historial de revisiones (primera versión, publicada el 2 Sep 2026)
Primera publicación

«En las pruebas pasaba, pero en un PC cuya ruta contiene un espacio la herramienta externa no arranca.» «Pasé C:\data\ y se fusionó con el argumento siguiente en uno solo.» «Pasé JSON como argumento, desaparecieron las comillas y la otra parte no pudo analizarlo.» Son fallos que se repiten en el código que lanza procesos hijos. La mayoría no los causa la lógica, sino un código escrito sin la premisa de que Windows no tiene un mecanismo para pasar una «matriz de argumentos».

Lo que recibe CreateProcess, la función que crea un proceso en Windows, es una sola cadena llamada lpCommandLine. Por mucho cuidado con que el llamador prepare una matriz, siempre se concatena en una cadena al cruzar el límite del sistema operativo, y el lado receptor la vuelve a dividir. Las reglas de división las decide el runtime del lado receptor, y el runtime de C, CommandLineToArgvW, el runtime de .NET y cmd.exe son cada uno un código distinto. Pasar argumentos es ensamblar una cadena que el analizador de la otra parte vuelva a dividir en las piezas originales.

Este artículo adopta el punto de vista de lanzar procesos hijos desde código Win32 y .NET, no desde scripts de PowerShell, y expone dónde se concatena la cadena, dónde se divide y qué reglas se aplican. El lado de PowerShell (el cambio del paso de argumentos en 7.3, --%, $PSNativeCommandArgumentPassing) se trata en «Cómo llamar correctamente a un exe externo desde PowerShell», así que este artículo profundiza en la capa de debajo.

La capa que cubre este artículoEl paso de argumentos de PowerShell se trata en un artículo aparte; este artículo cubre la capa de debajo, desde Win32 CreateProcess y .NET ProcessStartInfo hasta el analizador del exe de destinoAlcance de este artículoPaso de argumentos de PowerShell (artículo aparte).NET ProcessStartInfoWin32 CreateProcessWUna sola cadena de línea de comandosEl analizador del exe de destino

Figura 1: Debajo de PowerShell están las capas .NET y Win32, y desde cualquiera que se lance, el resultado es una sola cadena. Este artículo trata las reglas de esa capa.

1. Primero la conclusión

  • Un proceso de Windows nunca recibe una matriz de argumentos. La cadena única pasada a CreateProcess llega al proceso nuevo (el sistema operativo solo puede completar con la ruta completa el nombre del ejecutable inicial), y GetCommandLineW la devuelve. argv lo crea el lado receptor por sí mismo.1 2
  • El núcleo de las reglas de división son tres puntos: dividir por espacios y tabuladores, no dividir dentro de una región entre comillas dobles, y una barra invertida solo es especial cuando una comilla doble la sigue de inmediato (2n barras invertidas se convierten en n más la comilla abre o cierra el entrecomillado; 2n+1 se convierten en n más una comilla literal).3 4
  • Solo el token inicial (argv[0], el nombre del ejecutable) sigue otra regla: se puede entrecomillar, pero el escape con barra invertida no se aplica. Si lpApplicationName es NULL, la interpretación de una ruta con espacios se vuelve ambigua y se prueba primero C:\Program.exe.1 4
  • En el lado del ensamblado basta una regla: «si el argumento contiene un espacio o una comilla, o está vacío, entrecomíllelo, duplique las barras invertidas que preceden a una comilla y las barras invertidas finales, y escriba las comillas como \"ProcessStartInfo.ArgumentList en .NET Core 2.1 y posteriores lo hace por usted.5 6
  • No genere la forma que coloca dos comillas adyacentes dentro de un argumento no vacío (algo como "ab""c"), porque los receptores la interpretan de distinta manera. El "" que representa un argumento vacío es otra cosa y es correcto. cmd.exe y los archivos por lotes quedan fuera de estas reglas, así que no haga pasar por ellos valores no fiables.6 7
  • Los límites son 32.767 unidades de código UTF-16 para lpCommandLine (incluido el carácter nulo de terminación; los caracteres que son pares sustitutos, como un emoji, cuentan como dos) y 8.191 caracteres para cmd.exe. Si es probable superarlos, pase a un archivo de respuesta, pero solo cuando el destino pueda leer uno (o se pueda corregir para que lo lea) con una sintaxis como @file.1 8

En el diagrama, una línea continua marca una relación que siempre se cumple y una línea discontinua una relación condicional (las condiciones están en la explicación de cada relación en la página de detalle). La lista completa de relaciones (28 en total, con evidencia y grado de certeza) y las definiciones de los conceptos principales están reunidas en la página de detalle del mapa de conocimiento (en japonés). Datos: JSON-LD / Turtle

2. No existe una matriz de argumentos — CreateProcess y la cadena única

El segundo parámetro de CreateProcessW, lpCommandLine, es una sola cadena terminada en nulo en la que el nombre del ejecutable y los argumentos se disponen separados por espacios. El límite de longitud es de 32.767 unidades de código UTF-16 incluido el carácter nulo de terminación (el número de elementos wchar_t; un carácter de par sustituto como un emoji consume dos por carácter, así que nunca compruebe de antemano por el recuento aparente de caracteres), y como la versión Unicode puede modificar esta cadena, pasar un literal de cadena o un búfer const puede provocar una violación de acceso.1

Esta cadena se entrega tal cual al proceso nuevo como parte de sus parámetros de proceso, y el proceso hijo la recupera con GetCommandLineW. Como el sistema operativo puede completar con la ruta completa el nombre del ejecutable inicial, la cadena que ve el hijo no tiene por qué coincidir exactamente con la que pasó el padre.2 El lpCmdLine que se pasa al WinMain de una aplicación GUI es esta cadena sin el nombre del programa.9

El camino que siguen los argumentos para llegar al proceso hijoLa matriz de argumentos del llamador se concatena en una sola cadena en lpCommandLine de CreateProcess y se pasa al proceso nuevo, y el proceso hijo divide con su propio analizador la cadena que recupera con GetCommandLineW para crear argvLa matriz de argumentos del llamadorConcatenada en una cadena (responsabilidad del llamador)lpCommandLine de CreateProcessWLos parámetros de proceso del proceso nuevoLa cadena que devuelve GetCommandLineWEl analizador del lado receptor la divideLa matriz argv / args

Figura 2: La matriz no cruza el límite. La concatenación es responsabilidad del llamador, la división la del receptor, y la matriz original solo se restaura cuando coinciden las reglas de ambos lados.

El punto que hay que retener aquí es que la concatenación y la división ocurren en procesos distintos, en código distinto. El llamador no puede concatenar bien sin saber con qué va a dividir la otra parte, y el receptor no tiene forma de saber cómo se concatenó la cadena. En sistemas de tipo Unix, execve acepta la matriz tal cual, así que este problema no existe. Es una premisa propia de Windows, pero que acompaña a cada arranque de proceso.

3. Quién divide — tres analizadores

En el lado receptor, el código que divide la cadena en argv aparece sobre todo en tres clases.

Lado receptor Código que divide Cuándo se invoca
main / wmain en C/C++ El código de arranque del runtime de C de MSVC Crea argc / argv automáticamente al arrancar el programa4
Código que usa la API de Win32 directamente CommandLineToArgvW Se le pasa el valor de retorno de GetCommandLineW para convertirlo a forma argv3
Main(string[] args) / Environment.GetCommandLineArgs() de .NET (la configuración habitual lanzada a través del apphost o dotnet.exe) El código de arranque del runtime de C del host (apphost / dotnet.exe) En Windows el host es un programa wmain; toma el argv que construyó el runtime de C, quita sus propias opciones y la ruta de la aplicación, y pasa el resto al runtime junto con la ruta de la aplicación. Al arrancar el runtime construye una matriz cuyo primer elemento es el nombre del programa (el nombre de lanzamiento pasado por el host, o la ruta del ensamblado si no hay) y la conserva para GetCommandLineArgs(), mientras que Main recibe en args solo los argumentos sin el nombre del programa10 11 12
Una configuración que carga el runtime de .NET como biblioteca hospedada y no recibe argumentos de arranque El código de división propio del runtime de .NET (SegmentCommandLine) Como alternativa, GetCommandLineArgs() divide él mismo el valor de retorno de GetCommandLineW. Está implementado para coincidir con las reglas del runtime de C y no usa CommandLineToArgvW, porque «se comporta de forma ligeramente distinta»12

El código de división viene en tres linajes, el código de arranque del runtime de C, CommandLineToArgvW y el código de división propio del runtime de .NET, e implementan reglas con el mismo esqueleto, pero no son el mismo código. Una aplicación .NET lanzada a través del apphost o dotnet.exe se divide de hecho según las reglas del primer linaje (código de arranque del runtime de C), porque el propio host es un programa wmain construido con el runtime de C de MSVC. El código fuente del runtime de .NET sigue llevando un comentario que dice que no se usa CommandLineToArgvW porque se comporta de forma ligeramente distinta.12 Las diferencias aparecen en los bordes, como el tratamiento de "" que se describe más adelante, y los argumentos cotidianos rara vez las alcanzan, pero asumir que «las reglas son las mismas, así que vale cualquier cosa» es lo que se rompe en los bordes.

Los tres analizadores del lado receptorLa cadena única que devuelve GetCommandLineW la divide el código de arranque del runtime de C para C/C++, CommandLineToArgvW para uso directo de Win32, y el código de división propio del runtime para .NET cargado como biblioteca hospedada; cada uno sigue reglas con el mismo esqueleto pero es una implementación distinta. Una aplicación .NET normal lanzada a través del apphost o dotnet.exe recibe la matriz que dividió el código de arranque del runtime de C del hostLa cadena de GetCommandLineWCódigo de arranque del runtime de CCommandLineToArgvWCódigo de división propio de .NET (al cargar un host).NET a través de apphost / dotnet.exe es lo mismoMismo esqueleto de reglas, implementaciones distintas

Figura 3: Hay tres linajes de código de división. Una aplicación .NET lanzada a través del apphost o dotnet.exe recibe la matriz que dividió el código de arranque del runtime de C del host, y el código de división propio del runtime es la alternativa de la configuración como biblioteca hospedada. Como desde fuera no se ve cuál ejecuta el exe de destino, la respuesta práctica es ensamblar una cadena que dé el mismo resultado en todos.

Tenga en cuenta que args en Main(string[] args) de .NET no incluye el nombre del programa, mientras que el primer elemento de Environment.GetCommandLineArgs() sí. Este último ocupa la misma posición que argv[0] en C/C++.13 En un arranque normal como dotnet app.dll x, el host quita las opciones del host y la ruta de la aplicación (dotnet.exe y app.dll), y solo x llega a args en Main.14 GetCommandLineArgs(), en cambio, devuelve la matriz a la que el runtime antepuso el nombre del programa al arrancar (la ruta de app.dll seguida de x).11 El código de división propio del runtime solo divide GetCommandLineW en la configuración como biblioteca hospedada que no recibe argumentos de arranque; en una configuración en la que un host nativo pasa su propio argc/argv y llama a Main, args en Main es lo que pasó el host.

4. Las reglas de división — espacios, comillas y barras invertidas

Estas son las reglas que comparten los tres analizadores, para argv[1] en adelante.3 4

  1. Los argumentos se separan por espacios o tabuladores.
  2. Una región envuelta en comillas dobles se convierte en un argumento aunque contenga espacios. Las comillas mismas no forman parte del argumento. Una comilla puede empezar a mitad de un argumento, y si la cadena termina sin comilla de cierre, todo hasta el final se convierte en el último argumento.
  3. Una barra invertida se trata como un carácter ordinario. Solo cuando una comilla doble la sigue de inmediato se aplican las reglas siguientes.
  4. Si 2n barras invertidas preceden a una comilla doble, se emiten n barras invertidas, y la comilla actúa como «inicio o fin del entrecomillado».
  5. Si 2n+1 barras invertidas preceden a una comilla doble, se emiten n barras invertidas y una comilla literal, y el estado de entrecomillado no cambia.
  6. El acento circunflejo (^) no es un carácter de escape (esa es una regla de cmd.exe, no una regla del analizador).

El analizador guarda un bit de estado, «estoy entre comillas», lo invierte en cada comilla y lee la cadena de izquierda a derecha. Si un espacio separa argumentos lo decide este estado.

El flujo de división que alterna entre el interior y el exterior de las comillasFuera de comillas el analizador separa argumentos por espacios; cuando encuentra una comilla entra y trata los espacios como parte del argumento; cuando encuentra otra comilla vuelve al exterior. Una barra invertida solo se trata de forma especial cuando una comilla la sigue de inmediatoSe encuentra una comillaSe encuentra una comillaUna barra invertida va seguida de inmediato de una comillaUna barra invertida va seguida de inmediato de una comilla2n: emitir n y abrir/cerrar2n+1: emitir n y una comilla literalFuera de comillas: dividir por espaciosDentro de comillas: los espacios son parte del argumentoAplicar la regla de la barra invertidaInvertir el estado de entrecomilladoMantener el estado de entrecomillado

Figura 4: El núcleo de la división lo decide un solo bit, «dentro o fuera de comillas», y el número de barras invertidas inmediatamente anteriores a una comilla.

Más que memorizar las reglas en prosa, es más fiable mirar la correspondencia entre entrada y salida.

Parte de la línea de comandos (entrada) Argumentos resultantes Regla en juego
a b c a, b, c Dividir por espacios
"a b" c a b, c Una región entrecomillada no se divide
C:\data\ next C:\data\, next La barra invertida no va seguida de una comilla, así que es un carácter ordinario
"C:\data\\" next C:\data\, next Las dos anteriores a la comilla se convierten en una, y la comilla cierra
"C:\data\" next C:\data" next Una barra invertida, así que la comilla se convierte en comilla literal y el entrecomillado nunca cierra, se traga el argumento siguiente
"say \"hi\"" say "hi" Un recuento impar, así que comillas literales
"" Cadena vacía La única forma de pasar un argumento vacío
'a b' 'a, b' Las comillas simples no tienen un significado especial15

La fila 5 es la verdadera identidad de «pasé C:\data\ y se fusionó con el argumento siguiente en uno solo» de la apertura. En el momento en que entrecomilla una ruta con barra invertida final, la comilla de cierre se convierte en un carácter y el entrecomillado nunca cierra.

Cómo una barra invertida final se traga el argumento siguienteCuando se entrecomilla una ruta con barra invertida final, la comilla que debería cerrar queda justo después de una sola barra invertida y se interpreta como comilla literal, de modo que el entrecomillado nunca cierra y todo hasta el argumento siguiente se lee como un argumentoDuplicar la barra invertidaUna ruta entrecomillada que termina en una barra invertidaUn número impar de barras invertidas precede a la comilla de cierreLa comilla se emite como carácter y el entrecomillado no cierraLos espacios posteriores ya no separanTodo hasta el argumento siguiente llega como un argumentoEl entrecomillado cierra y los argumentos se separan

Figura 5: Por qué es necesario «duplicar la barra invertida final». Un entrecomillado escrito sin conocer las reglas se rompe al final de una ruta.

«Dos comillas consecutivas dentro del entrecomillado», donde las implementaciones divergen

Las reglas del runtime de C de MSVC tienen un punto más: «dos comillas consecutivas dentro de una cadena entrecomillada se tratan como una comilla» (una forma como "ab""c", que es un asunto distinto del "" que representa un argumento vacío).4 Las reglas oficiales de CommandLineToArgvW, sin embargo, no tienen ese punto, y el código de ensamblado del runtime de .NET evita de forma explícita generar esta forma porque «una comilla que sigue a una comilla de cierre la interpretan de distinta manera VC anterior y posterior a 2008».6

Como receptor, basta saber que esa entrada puede llegar. Como ensamblador, cuando quiera pasar una comilla como carácter, use solo la forma \". Da el mismo resultado en cada analizador.

5. argv[0] sigue otra regla — lpApplicationName y el problema de Program.exe

El token inicial, es decir, el nombre del ejecutable, queda fuera de las reglas anteriores. Se supone que es una cadena válida como ruta del sistema de archivos, así que se puede entrecomillar para incluir espacios, pero las reglas de escape de barras invertidas no se aplican. Tampoco hay forma de incluir una comilla en el propio argv[0].4 3 El código de ensamblado de .NET también trata el primer elemento por separado: «entrecomillarlo si tiene espacios, y lanzar una excepción si contiene una comilla».6

Lo que se convierte en un problema en el lado llamador es el comportamiento cuando lpApplicationName de CreateProcess es NULL. En ese caso, el módulo que se va a ejecutar se infiere del token inicial delimitado por espacios de lpCommandLine. Cuando la ruta contiene espacios surgen varios candidatos, y el sistema operativo los prueba empezando por el más corto.1

El orden en que se infiere el ejecutable cuando lpApplicationName es NULLSi se pasa C:\Program Files\MyApp -L -S sin comillas, CreateProcess prueba C:\Program.exe y luego C:\Program Files\MyApp.exe en ese orden, de modo que si se ha colocado C:\Program.exe ahí, ese es el que se ejecutaExisteNo existePasar lpApplicationName, o entrecomillar el token inicialPasar una ruta sin entrecomillar (con espacios) en lpCommandLineCandidato 1: probar C:\Program.exeArranca un ejecutable no deseadoCandidato 2: probar C:\Program Files\MyApp.exeArranca el ejecutable deseado

Figura 6: Colocar una ruta con espacios al inicio sin comillas hace que el sistema operativo pruebe candidatos desde el más corto. La documentación oficial llama a esto «peligroso» en términos claros.

La documentación oficial indica que si se coloca C:\Program.exe ahí, se ejecuta en lugar de la aplicación deseada, y pide no pasar NULL en lpApplicationName y, si se hace, entrecomillar la ruta inicial.1 En la práctica, haga las dos cosas. Pase la ruta completa del ejecutable en lpApplicationName, y coloque también la misma ruta, entrecomillada, al inicio de lpCommandLine. Cuando se pasan ambas, el módulo que se ejecuta lo decide lpApplicationName, y el argv[0] del proceso hijo se convierte en el token inicial de lpCommandLine. Si no mantiene las dos coherentes por convención, se rompe el código que obtiene su propia ruta a partir de argv[0]. La forma fiable de obtener la propia ruta es GetModuleFileNameW.4

Cómo se deciden el módulo ejecutado y argv[0]Cuando se pasan tanto lpApplicationName como lpCommandLine, el módulo que se ejecuta lo decide lpApplicationName y el argv[0] del hijo es el token inicial de lpCommandLine. El código que obtiene su propia ruta a partir de argv[0] se rompe cuando divergen, así que obtenga su propia ruta con GetModuleFileNameWSe rompe cuando divergenUsar en su lugarlpApplicationNameEl módulo que se ejecutaEl token inicial de lpCommandLineEl argv[0] del hijoCódigo que obtiene su propia ruta a partir de argv[0]GetModuleFileNameW

Figura 7: «Qué se ejecuta» y «qué entra en argv[0]» se deciden por separado. Un diseño que obtiene su propia ruta a partir de argv[0] no se sostiene sobre esta separación.

Un punto más: cuando lpApplicationName es NULL, la parte del nombre del ejecutable de lpCommandLine está limitada a MAX_PATH.1 Para el tratamiento de rutas largas, véase «MAX_PATH y las trampas de rutas y nombres de archivo en Windows».

6. Las reglas del lado del ensamblado — basta una función

Una vez conocidas las reglas de división, se puede ensamblar «una cadena que la otra parte vuelva a dividir en lo original» simplemente recorriéndolas al revés. Para cada argumento a partir de argv[1], haga lo siguiente.6

  1. Si no está vacío y no contiene ni espacios ni comillas, colóquelo tal cual.
  2. En caso contrario, entrecomille el conjunto. Dentro del entrecomillado,
    • convierta una racha de k barras invertidas inmediatamente anteriores a una comilla en 2k+1 y luego coloque la comilla (hacer el recuento impar la convierte en una «comilla literal»);
    • convierta una racha de k barras invertidas finales en 2k (preceden a la comilla de cierre, así que un recuento par la convierte en el «fin del entrecomillado»);
    • deje cualquier otra barra invertida tal cual.
  3. Coloque una cadena vacía como "".
El flujo de decisión para ensamblar un argumentoSi el argumento no está vacío y no contiene ni espacios ni comillas, se coloca tal cual; en caso contrario se entrecomilla, las barras invertidas anteriores a una comilla pasan a 2k+1 y las barras invertidas finales a 2k, se antepone una barra invertida a las comillas y se cierraNoRecibir un argumento¿Vacío, o contiene un espacio o una comilla?Colocarlo tal cualComilla de aperturaRecorrer de izquierda a derechak barras invertidas antes de una comilla → 2k+1k barras invertidas finales → 2kTodo lo demás tal cualComilla de cierre

Figura 8: El ensamblado es la inversa de las reglas de división. Solo hay tres ramas, y se ajusta el recuento de barras invertidas solo al final y justo antes de una comilla; con eso, cualquier cadena hace el ida y vuelta, siempre que el lado receptor divida caracteres anchos con las mismas reglas de división que CommandLineToArgvW, el runtime de C y .NET (capítulo 4) (un destino que interpreta la línea de comandos en bruto con su propia gramática, o un analizador de shell intercalado, queda fuera de alcance), no haya habilitado la expansión de comodines como wsetargv.obj, la cadena no contenga caracteres NUL y la cadena ensamblada quepa en el límite de lpCommandLine (32.767 unidades de código UTF-16 incluido el carácter nulo de terminación). (La línea de comandos es una cadena terminada en nulo, así que un carácter NUL es lo único que no se puede pasar en principio. En un destino con la expansión de comodines habilitada, un argumento que contenga * o ? se sustituye por nombres de archivo; véase el capítulo 8. Una cadena por encima del límite la rechaza CreateProcessW; véase el capítulo 10.)

Esta regla refleja tal cual la asimetría «una barra invertida solo es especial justo antes de una comilla». No hace falta duplicar de forma mecánica las barras invertidas que separan componentes de ruta; el punto es que solo se tocan las inmediatamente anteriores a una comilla y las del final.

7. Implementación en .NET — ArgumentList y Arguments

ProcessStartInfo en .NET Core 2.1 y posteriores tiene ArgumentList, que asume este ensamblado. Un elemento es un argumento, las cadenas que se añaden no necesitan escape previo, y en Process.Start .NET las ensambla internamente en una cadena y se la entrega al sistema operativo.5

var psi = new ProcessStartInfo
{
    FileName = @"C:\Program Files\MyTool\convert.exe",
    UseShellExecute = false,
};
psi.ArgumentList.Add("--input");
psi.ArgumentList.Add(inputPath);      // puede contener espacios, barras invertidas finales y comillas
psi.ArgumentList.Add("--output");
psi.ArgumentList.Add(outputPath);
psi.ArgumentList.Add("--label");
psi.ArgumentList.Add("");             // un argumento vacío se pasa correctamente como ""

using var proc = Process.Start(psi)
    ?? throw new InvalidOperationException("Process.Start devolvió null");
proc.WaitForExit();
if (proc.ExitCode != 0)
    throw new InvalidOperationException($"convert.exe falló (ExitCode={proc.ExitCode})");

Arguments es una propiedad que pasa tal cual una sola cadena que usted mismo ensambló. Las dos son independientes, y cuando se usa una, la otra debe estar vacía.16 La documentación oficial también aconseja elegir ArgumentList si no se tiene confianza en el entrecomillado.5

Dónde ArgumentList y Arguments se convierten en una cadenaCon ArgumentList, .NET escapa cada elemento y ensambla una sola cadena antes de pasarla a CreateProcess; con Arguments, la cadena que ensambló el llamador se pasa tal cual. En ambos casos, lo que llega al sistema operativo es una sola cadenaArgumentList (1 elemento = 1 argumento).NET escapa cada elemento y concatenaArguments (una sola cadena ensamblada por usted)Tal cualUna sola cadena de línea de comandosCreateProcess

Figura 9: Use lo que use, lo que llega al sistema operativo es una sola cadena. La única diferencia es quién la ensambla, y ArgumentList deja eso al lado que conoce las reglas.

El código de ensamblado detrás de ArgumentList es exactamente las reglas del capítulo 6. Si el argumento no está vacío y no contiene ni espacios ni comillas, se coloca tal cual; en caso contrario se entrecomilla, las barras invertidas inmediatamente anteriores a una comilla pasan a 2k+1, las barras invertidas finales a 2k, y a cada comilla se le antepone siempre una barra invertida. Nunca genera la forma con comillas adyacentes dentro de un argumento no vacío. Solo un argumento vacío se coloca como "", y esa es la forma correcta.6

En .NET Framework, ensámblelo usted mismo

ArgumentList es una API introducida en .NET Core 2.1 y no existe en ProcessStartInfo de .NET Framework.5 En una aplicación .NET Framework 4.8, o una herramienta interna construida sobre una, escriba usted mismo las reglas del capítulo 6 y pase el resultado a Arguments.

// Para .NET Framework. Ensambla la cadena única que se pasa a ProcessStartInfo.Arguments.
// Las reglas son las mismas que ProcessStartInfo.ArgumentList usa internamente.
static string BuildArguments(IEnumerable<string> args)
{
    var sb = new StringBuilder();
    foreach (var arg in args)
    {
        if (sb.Length > 0) sb.Append(' ');
        AppendArgument(sb, arg);
    }
    return sb.ToString();
}

static void AppendArgument(StringBuilder sb, string arg)
{
    if (arg.IndexOf('\0') >= 0)
        throw new ArgumentException("Un argumento no puede contener un carácter NUL (la línea de comandos es una cadena terminada en nulo y se cortaría ahí)");

    bool needsQuote = arg.Length == 0 || arg.Any(c => char.IsWhiteSpace(c) || c == '"');
    if (!needsQuote)
    {
        sb.Append(arg);                       // tal cual
        return;
    }

    sb.Append('"');
    int i = 0;
    while (i < arg.Length)
    {
        int backslashes = 0;
        while (i < arg.Length && arg[i] == '\\') { i++; backslashes++; }

        if (i == arg.Length)
        {
            sb.Append('\\', backslashes * 2); // final: duplicado porque sigue la comilla de cierre
        }
        else if (arg[i] == '"')
        {
            sb.Append('\\', backslashes * 2 + 1).Append('"'); // antes de una comilla: duplicado más uno
            i++;
        }
        else
        {
            sb.Append('\\', backslashes).Append(arg[i]);      // todo lo demás: tal cual
            i++;
        }
    }
    sb.Append('"');
}

Aquí están las entradas y las salidas una al lado de la otra.

Valor que se quiere pasar Cadena que emite AppendArgument
strict strict
Cadena vacía ""
C:\Program Files\input "C:\Program Files\input"
C:\Program Files\input\ "C:\Program Files\input\\"
say "hi" "say \"hi\""
a\"b "a\\\"b"
C:\data\ (sin espacios) C:\data\

Fíjese en la última fila. Un valor que no contiene ni espacios ni comillas no se envuelve, así que la barra invertida final sale tal cual. Sin envolver, las reglas 4 y 5 nunca se disparan, y C:\data\ llega correctamente.

La elección del método de ensamblado según la versión de .NETEn .NET Core 2.1 o posterior, dejarlo a ProcessStartInfo.ArgumentList; en .NET Framework, ensamblar la cadena Arguments con una función propia que sigue las mismas reglas. En ninguno de los dos casos se escriben comillas a mano con concatenación de cadenasCore 2.1 o posteriorFramework¿Qué versión de .NET?Añadir a ArgumentList un elemento cada vezEnsamblar Arguments con una función propiaNunca escribir comillas a mano

Figura 10: Dos métodos, un principio. Cíñase a «nunca escribir comillas a mano» y la rotura al final de una ruta no ocurre.

Tenga en cuenta que con UseShellExecute = true el arranque pasa por ShellExecuteEx en lugar de CreateProcess, y el contenido de ArgumentList se convierte en los parámetros que se pasan al shell. Al abrir un documento o una URL, la asociación de archivos ensambla la línea de comandos real del controlador, así que la cadena ensamblada aquí no tiene por qué llegar al destino tal cual. Para usos en los que se redirige la salida o se necesita el código de salida de forma fiable, ponga UseShellExecute = false y diseñe el código para leer la salida estándar y el error estándar al mismo tiempo. Esa parte se trata en «Lista de verificación para manejar procesos secundarios de forma segura en aplicaciones de Windows».

8. Implementación en C++ / Win32

En C++ usted escribe ambos lados, ensamblado y división. Para el ensamblado, convierta las reglas del capítulo 6 directamente en una función.

#include <windows.h>
#include <string>
#include <stdexcept>
#include <string_view>
#include <vector>

// Añade un argumento para argv[1] en adelante. Las reglas son la inversa de las reglas de división de CommandLineToArgvW / CRT.
void AppendArgument(std::wstring& cmd, std::wstring_view arg)
{
    if (!cmd.empty()) cmd += L' ';
    if (arg.find(L'\0') != std::wstring_view::npos)
        throw std::invalid_argument("Un argumento no puede contener un carácter NUL (la línea de comandos es una cadena terminada en nulo y se cortaría ahí)");

    const bool needsQuote =
        arg.empty() || arg.find_first_of(L" \t\"") != std::wstring_view::npos;
    if (!needsQuote) { cmd += arg; return; }

    cmd += L'"';
    for (size_t i = 0; ; ) {
        size_t backslashes = 0;
        while (i < arg.size() && arg[i] == L'\\') { ++i; ++backslashes; }

        if (i == arg.size()) {
            cmd.append(backslashes * 2, L'\\');           // final: duplicado
            break;
        }
        if (arg[i] == L'"') {
            cmd.append(backslashes * 2 + 1, L'\\');       // antes de una comilla: duplicado más uno
            cmd += L'"';
        } else {
            cmd.append(backslashes, L'\\');               // todo lo demás: tal cual
            cmd += arg[i];
        }
        ++i;
    }
    cmd += L'"';
}

// argv[0] (el ejecutable) sigue otra regla: solo entrecomillarlo si tiene espacios. No puede contener una comilla.
std::wstring QuoteArgv0(std::wstring_view exe)
{
    if (exe.find(L'\0') != std::wstring_view::npos)
        throw std::invalid_argument("La ruta del ejecutable no puede contener un carácter NUL (tanto lpApplicationName como la línea de comandos se cortarían ahí, y podría arrancarse la ruta hasta ese punto)");
    if (exe.find(L'"') != std::wstring_view::npos)
        throw std::invalid_argument("La ruta del ejecutable no puede contener una comilla");
    if (exe.empty() || exe.find_first_of(L" \t") != std::wstring_view::npos)
        return L'"' + std::wstring(exe) + L'"';
    return std::wstring(exe);
}

En la llamada, pase la ruta completa del ejecutable en lpApplicationName y un búfer escribible en lpCommandLine.

const std::wstring exe = LR"(C:\Program Files\MyTool\convert.exe)";

std::wstring cmd = QuoteArgv0(exe);          // mantener argv[0] coherente con el ejecutable
AppendArgument(cmd, L"--input");
AppendArgument(cmd, inputPath);
AppendArgument(cmd, L"--output");
AppendArgument(cmd, outputPath);

std::vector<wchar_t> buffer(cmd.begin(), cmd.end());
buffer.push_back(L'\0');                     // CreateProcessW puede modificar la cadena

STARTUPINFOW si{}; si.cb = sizeof(si);
PROCESS_INFORMATION pi{};
if (!CreateProcessW(exe.c_str(),             // lpApplicationName: nunca NULL
                    buffer.data(),           // lpCommandLine: empieza por la misma ruta, entrecomillada
                    nullptr, nullptr, FALSE, CREATE_UNICODE_ENVIRONMENT,
                    nullptr, nullptr, &si, &pi)) {
    const DWORD err = GetLastError();
    // Registrar err aquí y devolverlo al llamador. No tragárselo
    return;
}
CloseHandle(pi.hThread);                     // el identificador del hilo principal no hace falta, cerrarlo primero

switch (WaitForSingleObject(pi.hProcess, INFINITE)) {   // añadir un tiempo de espera si hace falta
case WAIT_OBJECT_0: {                        // ha salido. Leer el código de salida solo en esta rama
    DWORD exitCode = 0;
    if (!GetExitCodeProcess(pi.hProcess, &exitCode)) {
        const DWORD err = GetLastError();
        // Registrar también el fallo de la obtención, y devolverlo al llamador como un fallo
    } else if (exitCode != 0) {
        // El destino arrancó pero su procesamiento falló. No tratarlo como 0;
        // registrar el código de salida y devolverlo al llamador (igual que la comprobación de ExitCode en el ejemplo de C#)
    }
    break;
}
case WAIT_TIMEOUT:
    // Sigue en ejecución. Llamar aquí a GetExitCodeProcess solo devuelve STILL_ACTIVE (259),
    // que no es un código de salida. Este ejemplo toma la política «plegar un tiempo de espera en un fallo»:
    // solo cuando la petición de terminación pasa vemos que sale, y entonces se sigue hacia CloseHandle más abajo.
    // Si la política es seguir esperando, no hacer break aquí y cerrar los identificadores (eso
    // soltaría al hijo mientras sigue en ejecución). Volver a esperar
    if (!TerminateProcess(pi.hProcess, 1)) {
        const DWORD err = GetLastError();
        // No se pudo terminar (derechos insuficientes, etc.). Esperar aquí con INFINITE haría
        // inútil el plazo añadido para impedir desbordes. Registrar err y devolver un fallo al
        // llamador sin esperar (el hijo se suelta mientras sigue en ejecución, registrar eso también)
        break;
    }
    WaitForSingleObject(pi.hProcess, INFINITE); // la petición de terminación pasó, así que ver la salida antes de cerrar
    // Devolver el tiempo de espera al llamador como un fallo
    break;
default: {                                   // WAIT_FAILED
    const DWORD err = GetLastError();
    // Registrar también el fallo de la propia espera
    break;
}
}
CloseHandle(pi.hProcess);                    // olvidar esto deja escapar un identificador en cada arranque
La división de papeles de los dos argumentos que se pasan a CreateProcessWlpApplicationName fija el módulo que se va a ejecutar, y lpCommandLine decide la cadena que el proceso hijo recibe a través de GetCommandLineW. Pasar lpCommandLine como búfer escribible y mantener el argv[0] inicial coherente con lpApplicationNameMantener coherentelpApplicationName: la ruta completa del ejecutableEl módulo que se va a ejecutar queda fijadolpCommandLine: un búfer escribibleLa cadena que el hijo recibe a través de GetCommandLineWToken inicial = argv[0]El resto = argumentos ensamblados con las reglas del capítulo 6

Figura 11: «Qué ejecutar» y «qué pasar» lo deciden argumentos distintos. Haga explícitos ambos y no ocurren ni el problema de Program.exe ni la violación de acceso de un búfer no escribible.

En el lado receptor, pase el valor de retorno de GetCommandLineW a CommandLineToArgvW para obtenerlo en forma argv. Libere el valor de retorno con un solo LocalFree. Hay comportamientos de borde: si lpCmdLine es una cadena vacía, se devuelve la ruta del ejecutable actual, y si empieza por un espacio, el primer argumento se convierte en una cadena vacía.3

int argc = 0;
LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc);
if (argv == nullptr) {
    const DWORD err = GetLastError();
    // Registrar también el fallo de análisis
    return 1;
}
for (int i = 0; i < argc; ++i) {
    // argv[0] es el nombre del ejecutable. El sistema operativo puede haber completado la ruta completa
}
LocalFree(argv);

Si usa main / wmain, el runtime de C hace lo mismo por usted al arrancar. Tenga en cuenta, sin embargo, que argv en main es una cadena estrecha convertida a la página de códigos actual, así que los caracteres que la página de códigos no puede representar (por ejemplo una ruta japonesa en un PC fuera de un entorno japonés) se pierden aquí. La función de ensamblado del capítulo 6 «hace el ida y vuelta» frente a receptores que dividen caracteres anchos tal cual, como wmain, CommandLineToArgvW y .NET. De forma predeterminada no se expanden los comodines, pero vincular setargv.obj (wsetargv.obj para wmain) hace que expanda * y ?.4 Si pasa un argumento que contiene * en un nombre de archivo a un destino con ese ajuste, los argumentos que llegan difieren de lo que pretendía.

9. Cuando se interponen cmd.exe y los archivos por lotes

Las reglas anteriores se aplican cuando la cadena va directamente de CreateProcess al exe de destino. Cuando cmd.exe se interpone, se añade una etapa más de interpretación.

cmd.exe trata &, |, ( y ) como sintaxis, y para pasarlos como argumentos hay que escaparlos con ^ o entrecomillarlos. El tratamiento de las comillas en la cadena que sigue a /c o /k tiene sus propias reglas, y si «se quitan las comillas exteriores» cambia con la presencia de /s, el número de comillas y la presencia de caracteres especiales.17 Además, un archivo por lotes recibe los argumentos no divididos sino como cadena en bruto de línea de comandos. La documentación oficial de PowerShell advierte con claridad contra pasar entradas no fiables a archivos por lotes.7 La documentación de CreateProcess dice que para lanzar un archivo por lotes se especifica cmd.exe en lpApplicationName y se pasa /c más el nombre del archivo por lotes, y luego anota que el equipo de ingeniería de MSRC no lo recomienda, con un enlace a la exposición de MS14-019.1 Lo que MS14-019 corrigió fue el problema de que, cuando se pasaba un archivo por lotes directamente a CreateProcess, cmd.exe se buscaba primero en el directorio actual y se podía secuestrar, y la recomendación de MSRC es «pasar la ruta plenamente cualificada de cmd.exe y hacer del archivo por lotes su argumento».18 En otras palabras, el problema es lanzar un archivo por lotes sin nombrar cmd.exe por su ruta completa (poner lpApplicationName en NULL y dejar que el nombre del archivo por lotes lo arranque), no el propio arranque /c con la ruta completa de cmd.exe en lpApplicationName.

cmd.exe en medio añade etapas de interpretaciónLanzar el exe de destino directamente significa que la división ocurre una vez, solo en el analizador del destino, pero pasar por cmd.exe /c añade la interpretación sintáctica de cmd.exe, y un archivo por lotes encima recibe la cadena en bruto, de modo que las reglas de entrecomillado cambian en cada etapaSu proceso → el exe de destinoDividir una vez, solo por el analizador del destinoSu proceso → cmd.exe /c → el exe de destinoSe añade la interpretación sintáctica de cmd.exe (ampersand, tubería, paréntesis, circunflejo)División por el analizador del destinoSu proceso → cmd.exe /c → un archivo por lotesEl archivo por lotes recibe la cadena en brutoHacer pasar valores no fiables se convierte en inyección de comandos

Figura 12: Cuantas más etapas, más se mezclan las reglas. Lance de forma directa lo que se pueda lanzar de forma directa, y nunca pase a un archivo por lotes valores venidos de fuera.

La decisión práctica es simple. Si el destino es un exe, no ponga cmd.exe en medio. Si no tiene más remedio que llamar a un .bat, el principio es no dejar que el archivo por lotes interprete valores venidos de fuera. Escriba los valores en un archivo, haga que el archivo por lotes pase solo la ruta de ese archivo, como cadena fija, al exe posterior, y lea el contenido del archivo en el lado del exe. Poner el valor en una variable de entorno no es un límite, porque en el momento en que el archivo por lotes lo expande como %VAR%, & y | los reinterpreta cmd.exe. Pasar por una variable de entorno solo es aceptable cuando el exe posterior lee la variable directamente sin pasar por el archivo por lotes. Si incluso eso es difícil, mueva el contenido del archivo por lotes a PowerShell o a un exe propio («¿Esa bat debería migrarse a PowerShell?»).

10. Límites de longitud

Los límites también difieren según la vía.

Vía Límite Fuente
lpCommandLine de CreateProcess 32.767 unidades de código UTF-16 (incluido el carácter nulo de terminación; un par sustituto cuenta como dos) 1
La parte del nombre del ejecutable cuando lpApplicationName es NULL MAX_PATH 1
La línea de comandos de cmd.exe (incluidas las líneas de un archivo por lotes) 8.191 caracteres 8
ProcessStartInfo.Arguments de .NET Longitud de cadena (unidades de código UTF-16) inferior a 32.699 16

Un diseño que alinea como argumentos valores de longitud variable como una lista de archivos alcanza el límite el día en que crece el recuento. Para usos que se acercan al límite, pase al método «archivo de respuesta»: escriba los argumentos en un solo archivo y pase solo la ruta de ese archivo. La solución oficial para el límite de cmd.exe es el mismo método.8 Ni CreateProcess ni cmd.exe, sin embargo, expanden el archivo por usted. Este método funciona solo si el programa de destino puede leer un archivo de respuesta con una sintaxis como @file, o si puede corregir el destino para que pueda. Si el destino es un exe comercial que no se puede modificar, la única opción es fraccionar las llamadas para que cada una quepa en el límite.

Los límites de pasar valores de longitud variable como argumentos, y la forma de eludirlosAlinear como argumentos valores de longitud variable como una lista de archivos alcanza, al crecer el recuento, el límite de 8191 caracteres de cmd.exe o el límite de 32767 unidades de código UTF-16 de CreateProcess. Si el destino puede leer un archivo de respuesta (o se puede corregir para), pasar al método de archivo de respuesta de escribir los valores en un archivo y pasar solo la ruta; si el destino es un exe comercial que no puede, fraccionar las llamadasEl destino puede leer un archivo de respuestaUn exe comercial que no puedeAlinear como argumentos valores de longitud variable (una lista de archivos, etc.)La cadena crece al crecer el recuentoSe alcanza el límite (cmd.exe 8.191 / CreateProcess 32.767)Un día el arranque falla de repenteEscribir los valores en un archivo y pasar solo la ruta (archivo de respuesta)Fraccionar las llamadas

Figura 13: El límite es el tipo de problema que «hoy está bien». Para argumentos que crecen en proporción al recuento, si el destino puede leer un archivo de respuesta (o se puede corregir para), hágalo desde el principio.

11. Comprobar qué llegó realmente

Antes de añadir entrecomillado por conjetura, el camino más corto es mirar los argumentos que llegaron al destino. Hay tres cosas que mirar, «la cadena ensamblada en el lado llamador», «la cadena que llegó al lado de destino» y «la matriz tras la división», y cuatro medios para hacerlo. Antes, una promesa. Use el medio que use, redacte los secretos antes de registrar una línea de comandos en un registro. Si el diseño pone contraseñas, claves de API o tokens en los argumentos, escribirlos tal cual deja los secretos en el registro, ya sea el registro del llamador o el registro de arranque del destino. Los registros se conservan más tiempo que el proceso y los ven más personas. Además, una línea de comandos la pueden leer otros procesos de la misma máquina, como con Process Explorer que se describe más adelante, así que la contramedida de fondo es un diseño que pase las contraseñas y los tokens no como argumentos sino por otra vía, como la entrada estándar o un almacén de configuración protegido; la redacción en los registros es una salvaguarda encima. O bien interprete los argumentos divididos (en el lado llamador, los elementos antes del ensamblado) y redacte los valores de opciones que podrían ser secretos antes de registrar, o bien active el registro de la cadena en bruto solo en un modo de diagnóstico restringido.

  1. En el lado llamador, registre la cadena que ensambló. Es el lpCommandLine justo antes de pasarlo a CreateProcess. Esta comparación supone un arranque con UseShellExecute = false o una llamada directa a CreateProcess. Cuando se abre un documento o una URL con UseShellExecute = true, la asociación de archivos ensambla la línea de comandos real a través de ShellExecuteEx (capítulo 7), así que la cadena del llamador y la del destino difieren incluso sin cmd.exe ni un archivo por lotes, y eso no es el problema del capítulo 9. Si usa ArgumentList de .NET, registrar la lista de elementos tal cual no sirve para la comparación. Los elementos son los valores antes del entrecomillado y antes de duplicar las barras invertidas finales, y lo que llega al sistema operativo es la cadena que .NET formateó a partir de ellos. O bien reconstruya una sola cadena a partir de los elementos con las mismas reglas que BuildArguments del capítulo 7 y registre esa (da el mismo resultado que el formateo que ArgumentList hace internamente), o bien compare la lista de elementos directamente con la matriz tras la división. Este es el único medio de ver «el búfer original del llamador»; Process Explorer y el registro del destino, que se describen más abajo, solo muestran la cadena que reconstruyó una etapa intermedia de cmd.exe o de un archivo por lotes. Al registrar, cumpla la promesa de la apertura y redacte los valores de elementos que podrían ser secretos (un elemento redactado ya no coincide con la cadena del destino, así que excluya ese elemento de la comparación).
  2. Prepare un exe que solo muestre sus argumentos. Láncelo en lugar del exe de destino y haga que imprima los args recibidos, uno por línea. Si escribe los valores tal cual, un argumento que contenga saltos de línea o caracteres de control puede aparecer como varias líneas o sobrescribir líneas vecinas y se equivoca al contar, así que imprima cada valor escapado como cadena JSON junto con su longitud (el escape es reversible, así que se puede recuperar el valor original). Recuerde, no obstante, que como explica el capítulo 3 hay tres linajes de analizador, y interpretan de distinta manera formas de borde como dos comillas consecutivas dentro del entrecomillado. Use un exe de visualización construido con el mismo runtime que el destino (C++ con wmain si el destino es C/C++ de MSVC, .NET si es .NET). Si el destino es su propio programa, el enfoque más fiable es saltarse el exe de visualización y registrar argv en el propio arranque del destino (bajo la regla de redacción del punto siguiente). Para .NET bastan las pocas líneas siguientes.
using System.Text.Encodings.Web;
using System.Text.Json;

// Escapar saltos de línea, caracteres de control, comillas y barras invertidas; emitir el texto japonés tal cual
var json = new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };

Console.WriteLine("CommandLine: " + JsonSerializer.Serialize(Environment.CommandLine, json)); // la cadena única
for (int i = 0; i < args.Length; i++)
    Console.WriteLine($"[{i}] len={args[i].Length} {JsonSerializer.Serialize(args[i], json)}");
    // Tras la división. Cada entrada cabe siempre en una línea, y una cadena vacía se muestra como len=0 y "". len está en unidades de código UTF-16
  1. Mire la línea de comandos del proceso hijo en Process Explorer. Las propiedades del proceso muestran la cadena de línea de comandos que sostiene el proceso hijo. Es un medio de comprobar «la cadena que llegó al lado de destino»; no le dice «la matriz tras la división». Lo que se muestra es la cadena que sostiene el lado del proceso hijo, así que, como se tocó en el capítulo 2, el sistema operativo puede haber completado con la ruta completa el nombre del ejecutable inicial, y si cmd.exe o un archivo por lotes está en medio, lo que ve es la cadena que reconstruyó cmd.exe. Los puntos clave son no alarmarse solo por una diferencia en el token inicial, y que la cadena original del llamador solo se puede conocer por el registro del punto 1. El uso se trata en «Process Explorer / Handle / VMMap en la práctica».
  2. En el arranque de su propia aplicación, registre la línea de comandos que recibió. Cuando alguien en el terreno dice «no arranca», tener un registro de la cadena con la que se arrancó permite aislar primero si es un problema de argumentos. Aquí tampoco guarde tal cual el valor de retorno de GetCommandLineW. Cumpla la promesa de la apertura: o bien interprete los argumentos divididos y redacte los valores que podrían ser secretos antes de registrar, o bien active el registro de la cadena en bruto solo en un modo de diagnóstico restringido.

El orden de comparación es el siguiente. Compare primero la cadena del llamador (punto 1) con la cadena del destino (punto 3 o 4). Si no coinciden aparte del nombre del ejecutable inicial, una etapa intermedia la transformó: cmd.exe o un archivo por lotes en un arranque directo (capítulo 9), o la asociación de archivos del shell para UseShellExecute = true (capítulo 7). Sustituir su código por la función del capítulo 6 no lo corrige. Si coinciden, compare esa cadena con la matriz tras la división (punto 2). Si está dividida según las reglas pero no es la matriz que quiere, el problema está en el lado del ensamblado; si no está dividida según las reglas, el problema es el analizador del receptor.

El orden para aislar un problema de argumentosComparar primero el registro de la cadena ensamblada en el lado llamador con la cadena del destino vista en Process Explorer o en el registro de arranque del destino. Si no coinciden aparte del nombre del ejecutable inicial, una etapa intermedia (cmd.exe o un archivo por lotes en un arranque directo, la asociación de archivos del shell para UseShellExecute=true) la transformó. Si coinciden, comparar con la matriz tras la división; si está dividida según las reglas pero no es la matriz que se quiere, el problema está en el lado del ensamblado, y si no está dividida según las reglas, el problema es el analizador del receptorNoSí: dividida, pero no es la matriz que se quiereNo: no está dividida según las reglasLos argumentos están malMirar la cadena ensamblada en el lado llamador (registro del llamador)Mirar la cadena del destino (Process Explorer / registro de arranque del destino)¿Coinciden aparte del nombre del ejecutable inicial?Una etapa intermedia la transformó (véanse los capítulos 9 y 7)Mirar la matriz tras la división (un exe de visualización en el mismo runtime que el destino)¿La cadena y la matriz se corresponden según las reglas?Un problema del lado del ensamblado: sustituir por la función del capítulo 6Un problema en el analizador del receptor

Figura 14: Compare en orden las tres cosas «la cadena del llamador», «la cadena del destino» y «la matriz», y se decide de forma mecánica si la responsabilidad está en una etapa intermedia, en el lado del ensamblado o en el lado receptor. Añadir escapes por conjetura puede esperar hasta después de esta comprobación.

12. Una guía aproximada (tabla de decisión)

Situación Qué hacer
Lanzar un exe desde .NET Core 2.1 o posterior / .NET 5 o posterior Añadir a ProcessStartInfo.ArgumentList un elemento cada vez
Lanzar un exe desde .NET Framework Ensamblar Arguments con una función que siga las reglas del capítulo 6. Nunca escribir comillas a mano
Lanzar desde C++ Pasar lpApplicationName y ensamblar lpCommandLine según las reglas en un búfer escribible
Quiere una comilla dentro de un valor de argumento Usar solo la forma \". Nunca colocar comillas adyacentes dentro de un argumento no vacío
La ruta termina en una barra invertida Si la envuelve, duplique la barra invertida final. Si no hay espacios, no la envuelva
Quiere pasar un argumento vacío Poner "". Si lo omite, desaparece el argumento entero
La ruta del ejecutable contiene un espacio Pasar lpApplicationName y entrecomillar también el token inicial
No tiene más remedio que llamar a un .bat No dejar que el archivo por lotes interprete valores venidos de fuera. Escribirlos en un archivo y hacer que los lea el exe posterior (una variable de entorno expandida como %VAR% dentro del archivo por lotes no es un límite)
Los argumentos se alargan Si el destino puede leer un archivo de respuesta (o se puede corregir para), pasar a un archivo de respuesta. Para un exe comercial, fraccionar las llamadas
No sabe qué está llegando Comparar los tres en orden: el registro del llamador, la cadena del destino (Process Explorer / registro de arranque) y la matriz tras la división (un exe de visualización en el mismo runtime que el destino)

13. Resumen

Los argumentos de línea de comandos de Windows cruzan el límite no como una matriz sino como una sola cadena. El llamador concatena, el receptor divide, y las reglas de división se reducen a tres: «dividir por espacios», «entrecomillar» y «solo las barras invertidas inmediatamente anteriores a una comilla son especiales». Solo el nombre del ejecutable inicial sigue otra regla, y omitir lpApplicationName hace ambigua la interpretación de una ruta con espacios.

Lo que el lado del ensamblado tiene que hacer cabe en una función, y en .NET Core 2.1 o posterior ArgumentList se encarga. Para el ejecutable, pase la ruta completa en lpApplicationName y coloque también la misma ruta, entrecomillada, al inicio de lpCommandLine (en .NET, déjelo a FileName). Nunca genere la forma con comillas adyacentes dentro de un argumento no vacío (el "" que representa un argumento vacío es otra cosa), nunca haga pasar valores venidos de fuera por cmd.exe o un archivo por lotes, y para argumentos que crecen en proporción al recuento, use un archivo de respuesta solo cuando el destino pueda leer uno (o se pueda corregir para), y si no, fraccione las llamadas. Cumpla estos cinco puntos y los fallos «solo no arranca en un PC con un espacio en la ruta» y «el argumento siguiente desaparece por una barra invertida final» no ocurren.

Cinco promesas que evitan los fallos de argumentosPasar la ruta completa del ejecutable en lpApplicationName y entrecomillar también el token inicial, dejar el entrecomillado a una función que sigue las reglas o a ArgumentList, nunca generar la forma con comillas adyacentes dentro de un argumento no vacío, nunca hacer pasar valores de fuera por cmd.exe o un archivo por lotes, y usar un archivo de respuesta para los argumentos que crecen con el recuento solo cuando el destino puede leer uno. Suponiendo que el destino interpreta según las reglas de división publicadas y no ha habilitado la expansión de comodines, estos cinco puntos evitan los fallos causados por rutas con espacios y barras invertidas finalesPasar la ruta completa en lpApplicationName y entrecomillar también el token inicialDejar el entrecomillado a una función conforme a las reglas o a ArgumentListNunca generar comillas adyacentes dentro del entrecomilladoNunca hacer pasar valores de fuera por cmd.exe o un archivo por lotesUsar un archivo de respuesta para argumentos que crecen (cuando el destino puede leer uno)Sin fallos por espacios o barras invertidas finales

Figura 15: Cada una de las cinco promesas es una reformulación de «fijar el módulo que se va a ejecutar y pasar solo cadenas que el analizador del destino pueda dividir». La premisa es que el destino interpreta según las reglas de división publicadas y no ha habilitado la expansión de comodines (capítulos 6 y 8); encima, estos cinco puntos evitan los fallos causados por espacios y barras invertidas finales.

Cuando no funciona, antes de añadir escapes por conjetura, mire las tres cosas: la cadena ensamblada en el lado llamador, la cadena que llegó al lado de destino y la matriz tras la división. Si las cadenas del llamador y del destino difieren, una etapa intermedia es responsable (cmd.exe o un archivo por lotes, o la asociación de archivos del shell para UseShellExecute = true); si son las mismas, la correspondencia entre la cadena y la matriz decide si es el lado del ensamblado o el lado receptor.

Artículos relacionados

Áreas de consultoría relacionadas

KomuraSoft LLC se ocupa del diseño de aplicaciones Windows que combinan herramientas externas y EXE internos, de la investigación de causas de arranques de procesos hijos que «arrancan en unos entornos y en otros no», y de la revisión del código de arranque de procesos como parte de la migración de .NET Framework a .NET. No dude en ponerse en contacto incluso por un solo caso de «los argumentos se deforman».

Referencias

  1. Microsoft Learn, CreateProcessW function (processthreadsapi.h). Sobre que lpCommandLine es una sola cadena de como máximo 32.767 caracteres (incluido el carácter nulo de terminación; unidades de código UTF-16, porque es una cadena ancha), que la versión Unicode puede modificar su contenido de modo que no se puede pasar memoria de solo lectura, que el token inicial delimitado por espacios se convierte en el nombre del módulo cuando lpApplicationName es NULL con una ruta que contiene espacios interpretada a partir de c:\program.exe, el peligro de que se ejecute otro ejecutable si se coloca Program.exe ahí y la necesidad de evitar NULL o entrecomillar la ruta, que argv[0] puede no coincidir con el nombre del módulo cuando se especifican ambos, que la parte del nombre de módulo está limitada a MAX_PATH cuando es NULL, y que hace falta cmd.exe /c para lanzar un archivo por lotes. Véase también la nota en CreateProcessA function de que el equipo de ingeniería de MSRC no recomienda este método (con un enlace a la exposición de MS14-019).  2 3 4 5 6 7 8 9 10

  2. Microsoft Learn, GetCommandLineW function (processenv.h). Sobre que devuelve la cadena de línea de comandos del proceso actual, que el valor de retorno no se debe liberar ni modificar, que se puede convertir a forma argv mediante CommandLineToArgvW, y que puede no coincidir con la cadena que el padre pasó a CreateProcess porque el sistema operativo completa la ruta completa del nombre del ejecutable.  2

  3. Microsoft Learn, CommandLineToArgvW function (shellapi.h). Sobre el tratamiento especial de las barras invertidas inmediatamente anteriores a una comilla doble (2n da n más la apertura o el cierre del entrecomillado, 2n+1 da n más una comilla literal, y permanecen tal cual cuando no sigue una comilla), que los espacios se convierten en parte del argumento en el modo «entre comillas», que el nombre de programa inicial se permite con o sin comillas, que el primer argumento se convierte en una cadena vacía cuando lpCmdLine empieza por un espacio, que se devuelve la ruta del ejecutable actual cuando se pasa una cadena vacía, y que el valor de retorno se libera con un solo LocalFree 2 3 4 5

  4. Microsoft Learn, main function and command-line arguments. Sobre las reglas con las que el código de arranque de Microsoft C/C++ interpreta la línea de comandos (separación por espacios y tabuladores, argv[0] entrecomillable pero no sujeto a las reglas siguientes, una cadena entrecomillada es un argumento, el circunflejo no es un carácter de escape, dos comillas consecutivas dentro de comillas son una comilla, todo hasta el final es el último argumento cuando no hay comilla de cierre, y el tratamiento de números pares e impares de barras invertidas), la tabla de entradas y argv, la expansión de comodines con setargv.obj, y que argv[0] puede no ser el nombre del ejecutable cuando se especifican tanto lpApplicationName como lpCommandLine, de modo que debe obtenerse con GetModuleFileName 2 3 4 5 6 7 8

  5. Microsoft Learn, ProcessStartInfo.ArgumentList Property. Sobre que las cadenas añadidas no necesitan escape previo, que ArgumentList y Arguments son independientes y no se pueden usar al mismo tiempo, que ArgumentList escapa los argumentos y ensambla internamente una sola cadena que se pasa al sistema operativo en Process.Start, que ArgumentList es la elección si no se tiene confianza en el entrecomillado, el peligro de combinarlo con datos no fiables, y que se aplica a .NET Core 2.1 y posteriores.  2 3 4

  6. dotnet/runtime (GitHub), PasteArguments.cs y PasteArguments.Windows.cs. El código de ensamblado usado dentro de ArgumentList. Sobre colocar tal cual un argumento no vacío sin espacios ni comillas, en caso contrario entrecomillarlo, duplicar las barras invertidas finales, hacer las barras invertidas anteriores a una comilla duplicadas más una, anteponer siempre una barra invertida a las comillas, no generar la forma de una comilla que sigue a una comilla de cierre porque VC anterior y posterior a 2008 la interpretan de distinta manera, y para argv[0] solo entrecomillarlo si tiene espacios y lanzar una excepción si contiene una comilla.  2 3 4 5 6

  7. Microsoft Learn, about_Parsing. Sobre que los argumentos de un archivo por lotes se pasan a cmd.exe como una cadena en bruto de línea de comandos, y la advertencia contra pasar entradas no fiables.  2

  8. Microsoft Learn, Command prompt (Cmd.exe) command-line string limitation. Sobre que la longitud máxima de una cadena usable en el símbolo del sistema es de 8.191 caracteres, que también se aplica a las líneas de comandos dentro de archivos por lotes, y la solución de escribir los argumentos en un archivo y pasar ese nombre de archivo.  2 3

  9. Microsoft Learn, WinMain function (winbase.h). Sobre que lpCmdLine es la línea de comandos sin el nombre del programa, que la línea de comandos completa se obtiene con GetCommandLine, y que existe wWinMain como punto de entrada Unicode. 

  10. dotnet/runtime (GitHub), apphost.c y dotnet.cpp. Sobre que los puntos de entrada del apphost y de dotnet.exe son wmain(int argc, wchar_t* argv[]) en Windows y pasan el argv construido por el runtime de C directamente al procesamiento de arranque del host. 

  11. dotnet/runtime (GitHub), corhost.cpp. Sobre que ExecuteAssembly construye la matriz de Environment.GetCommandLineArgs() con SetCommandLineArgs(pwzAssemblyPath, argc, argv), que el primer elemento es el nombre de lanzamiento pasado por el host (o la ruta del ensamblado si no hay) seguido de argv, y que solo ese argv se pasa a Main 2

  12. dotnet/runtime (GitHub), Environment.cs y Environment.Windows.cs. Sobre que GetCommandLineArgs devuelve la matriz inicializada al arrancar (s_commandLineArgs), que una biblioteca hospedada sin ella se remite a dividir el valor de retorno de GetCommandLineW con SegmentCommandLine propio del runtime, que esas reglas siguen la documentación de la función main de MSVC, y que no se usa CommandLineToArgvW porque su comportamiento difiere ligeramente.  2 3

  13. Microsoft Learn, Main() and command-line arguments. Sobre que args en Main nunca es null y que, a diferencia de C/C++, el nombre del programa no se incluye al inicio de args sino que es el primer elemento de GetCommandLineArgs()

  14. Microsoft Learn, dotnet command. Sobre que la ejecución de una aplicación toma la forma dotnet [runtime options] <app path> [arguments], donde todo lo que sigue a la ruta de la aplicación son los argumentos que se pasan a la aplicación. 

  15. Microsoft Learn, Environment.GetCommandLineArgs Method. Sobre que el primer elemento es el nombre del ejecutable, que los argumentos se separan por espacios y las comillas dobles permiten espacios dentro, que las comillas simples no tienen esa función, las reglas para números pares e impares de barras invertidas y comillas, y la tabla de entradas y resultados. 

  16. Microsoft Learn, ProcessStartInfo.Arguments Property. Sobre que la longitud de cadena es inferior a 32.699, que los argumentos los interpreta la aplicación de destino y por tanto deben coincidir con sus expectativas, que las comillas mismas no se pasan al destino cuando se entrecomilla un argumento que contiene espacios, y su independencia respecto de ArgumentList 2

  17. Microsoft Learn, cmd. Sobre que &, | y ( ) son caracteres especiales que requieren ^ o comillas, la lista de caracteres especiales que deberían entrecomillarse, las condiciones bajo las que se conservan las comillas con /c o /k (sin /s, exactamente un par de comillas, sin caracteres especiales, que contenga espacios y que sea un nombre de ejecutable), y cómo se quita la comilla inicial cuando no se cumplen las condiciones. 

  18. Microsoft Security Response Center, MS14-019 – Fixing a binary hijacking via .cmd or .bat file y Microsoft Security Bulletin MS14-019. Sobre que CreateProcess buscaba cmd.exe primero en el directorio actual cuando se le pasaba un .cmd / .bat de forma directa, lo que permitía un secuestro, que la corrección usa siempre el cmd.exe del sistema, y la recomendación de que las aplicaciones pasen la ruta plenamente cualificada de cmd.exe con el archivo por lotes como argumento. 

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.

¿No hay en Windows una API que pase una matriz de argumentos?
No. Lo que CreateProcess recibe es una sola cadena llamada lpCommandLine, y esa cadena es la que llega al proceso nuevo (el sistema operativo solo puede completar con la ruta completa el nombre del ejecutable inicial). Lo que parece una matriz argv lo crea el proceso receptor cuando el código de arranque del runtime de C, CommandLineToArgvW o el runtime de .NET divide la cadena. Pasar argumentos es, por tanto, lo mismo que ensamblar una cadena que el analizador de la otra parte vuelva a dividir en las piezas originales.
¿Cuándo se convierte una barra invertida en un carácter de escape?
Solo cuando una comilla doble la sigue de inmediato. Una barra invertida que no va seguida de una comilla doble permanece igual, por muchas que haya seguidas. Si 2n barras invertidas preceden a una comilla doble, se convierten en n barras invertidas y la comilla abre o cierra el entrecomillado; si la preceden 2n+1, se convierten en n barras invertidas y una comilla literal. Por esta asimetría, solo hay que duplicar una barra invertida final en una ruta cuando se entrecomilla la ruta.
¿Qué debo usar, ProcessStartInfo.ArgumentList o Arguments?
Si los valores vienen de variables, ArgumentList. Un elemento se convierte en un argumento, .NET aplica las comillas y el escape necesarios, y ensambla internamente una sola cadena antes de entregarla al sistema operativo. Arguments es una propiedad que pasa tal cual una cadena que usted mismo ensambló; las dos son independientes y no se pueden usar al mismo tiempo. Tenga en cuenta que ArgumentList es una API introducida en .NET Core 2.1 y no existe en .NET Framework. En .NET Framework, ensamble Arguments con la función de ensamblado de este artículo.
¿Se pueden escribir dos comillas adyacentes dentro de un argumento entrecomillado?
No la genere en el lado del ensamblado, porque los receptores la interpretan de distinta manera. Aquí se trata de entrecomillar un argumento no vacío y colocar dentro dos comillas adyacentes. El "" que representa un argumento vacío (solo dos comillas) es otra cosa, y es la forma correcta de pasar una cadena vacía. Según las reglas del runtime de C de MSVC, dos comillas consecutivas dentro de una cadena entrecomillada se tratan como una comilla, pero las reglas oficiales de CommandLineToArgvW no describen este tratamiento, y el código fuente del runtime de .NET dice explícitamente que no genera esa forma porque VC anterior y posterior a 2008 la interpretan de distinta manera. Cuando quiera pasar una comilla como carácter, ponga una barra invertida delante, y cada analizador da el mismo resultado.
Cuando la ruta del ejecutable contiene un espacio, ¿qué hay que pasar a CreateProcess para que sea seguro?
La forma fiable es pasar la ruta completa del ejecutable en lpApplicationName y colocar también la misma ruta, entrecomillada, al inicio de lpCommandLine. Si lpApplicationName es NULL, CreateProcess infiere el nombre del ejecutable desde el inicio de lpCommandLine, dividiendo por espacios. Para la cadena C:\Program Files\MyApp -L -S primero comprueba si existe C:\Program.exe, de modo que si hay un archivo malicioso ahí, ese es el que se ejecuta. La documentación oficial enuncia este peligro de forma explícita y pide evitar NULL o entrecomillar la ruta.
¿Se aplican las mismas reglas al pasar argumentos a un archivo por lotes?
No. Un archivo por lotes lo interpreta cmd.exe, y cmd.exe trata la línea de comandos como una cadena en bruto sin dividirla en argumentos. Símbolos como &, |, los paréntesis y ^ actúan como sintaxis de cmd.exe, así que entrecomillar según las reglas de CommandLineToArgvW no los hace seguros. La documentación oficial advierte contra pasar entradas no fiables a archivos por lotes. Escriba los valores en un archivo y haga que los lea el exe posterior, no el archivo por lotes, o mueva el contenido del archivo por lotes a PowerShell o a un exe propio. Poner el valor en una variable de entorno tampoco es un límite, porque en cuanto el archivo por lotes lo expande como %VAR%, cmd.exe reinterpreta los símbolos.

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