Pourquoi les arguments se cassent — Les règles des arguments de ligne de commande Windows

· Mis à jour le: · · Windows, Développement Windows, C#, C++, Win32 API, .NET, Processus

Historique des révisions (première version, publiée le 2 Sep 2026)
Première publication

« Ça passait en test, mais l’outil externe ne démarre pas sur un PC dont le chemin contient un espace. » « J’ai passé C:\data\ et ça a été fusionné avec l’argument suivant en un seul. » « J’ai passé du JSON en argument, les guillemets ont disparu, et l’autre côté n’a pas pu l’analyser. » Ce sont des échecs qui reviennent sans cesse dans le code qui lance des processus enfants. La plupart ne viennent pas de la logique, mais d’un code écrit sans la prémisse que Windows n’a pas de mécanisme pour passer un « tableau d’arguments ».

Ce que reçoit CreateProcess, la fonction qui crée un processus sous Windows, est une seule chaîne appelée lpCommandLine. Aussi soigneusement que l’appelant prépare un tableau, il est toujours concaténé en une chaîne lorsqu’il franchit la frontière du système d’exploitation, et le destinataire le découpe à nouveau. Les règles de découpage sont décidées par le runtime du destinataire, et le runtime C, CommandLineToArgvW, le runtime .NET et cmd.exe sont chacun un code distinct. Passer des arguments, c’est assembler une chaîne que l’analyseur de l’autre côté recouppe en morceaux d’origine.

Cet article se place du point de vue du lancement de processus enfants depuis du code Win32 et .NET, et non depuis des scripts PowerShell, et expose où la chaîne est concaténée, où elle est découpée, et quelles règles s’appliquent. Le côté PowerShell (le changement du passage d’arguments en 7.3, --%, $PSNativeCommandArgumentPassing) est traité dans « Bien appeler un exe externe depuis PowerShell », aussi cet article creuse la couche en dessous.

La couche que cet article couvreLe passage d'arguments PowerShell est traité dans un article distinct ; cet article couvre la couche en dessous, de Win32 CreateProcess et .NET ProcessStartInfo jusqu'à l'analyseur de l'exe ciblePérimètre de cet articlePassage d'arguments PowerShell (article distinct).NET ProcessStartInfoWin32 CreateProcessWUne seule chaîne de ligne de commandeL'analyseur de l'exe cible

Figure 1 : Sous PowerShell se trouvent les couches .NET et Win32, et d’où que l’on lance, le résultat est une seule chaîne. Cet article traite les règles de cette couche.

1. D’abord la conclusion

  • Un processus Windows ne reçoit jamais un tableau d’arguments. La chaîne unique passée à CreateProcess parvient au nouveau processus (le système d’exploitation peut seulement compléter le nom de l’exécutable en tête par le chemin complet), et GetCommandLineW la renvoie. argv est créé par le destinataire lui-même.1 2
  • Le cœur des règles de découpage tient en trois points : découper sur les espaces et les tabulations, ne pas découper à l’intérieur d’une zone entre guillemets doubles, et une barre oblique inverse n’est particulière que lorsqu’un guillemet double la suit immédiatement (2n barres obliques inverses deviennent n plus le guillemet ouvre ou ferme le quotage ; 2n+1 deviennent n plus un guillemet littéral).3 4
  • Seul le jeton de tête (argv[0], le nom de l’exécutable) suit une autre règle : on peut l’entourer de guillemets, mais l’échappement par barre oblique inverse ne s’applique pas. Si lpApplicationName est NULL, l’interprétation d’un chemin contenant des espaces devient ambiguë et C:\Program.exe est essayé en premier.1 4
  • Côté assemblage, une règle suffit : « si l’argument contient un espace ou un guillemet, ou s’il est vide, l’entourer de guillemets, doubler les barres obliques inverses qui précèdent un guillemet et les barres obliques inverses finales, et écrire les guillemets en \". » ProcessStartInfo.ArgumentList dans .NET Core 2.1 et versions ultérieures le fait pour vous.5 6
  • Ne générez pas la forme qui place deux guillemets adjacents à l’intérieur d’un argument non vide (quelque chose comme "ab""c"), parce que les destinataires l’interprètent différemment. Le "" qui représente un argument vide est autre chose et est correct. cmd.exe et les fichiers batch se situent hors de ces règles, aussi ne faites pas transiter par eux des valeurs non fiables.6 7
  • Les limites sont 32 767 unités de code UTF-16 pour lpCommandLine (y compris le caractère nul terminal ; les caractères en paires de substitution, comme les emoji, comptent pour deux) et 8 191 caractères pour cmd.exe. Si vous risquez de les dépasser, passez à un fichier de réponse, mais seulement lorsque la cible peut en lire un (ou peut être corrigée pour en lire un) avec une syntaxe telle que @file.1 8

Dans le diagramme, un trait continu marque une relation qui vaut toujours et un trait pointillé une relation conditionnelle (les conditions figurent dans l’explication de chaque relation sur la page de détail). La liste complète des relations (28 au total, avec preuve et niveau de certitude) et les définitions des concepts principaux sont rassemblées sur la page de détail de la carte des connaissances (en japonais). Données : JSON-LD / Turtle

2. Il n’y a pas de tableau d’arguments — CreateProcess et la chaîne unique

Le second paramètre de CreateProcessW, lpCommandLine, est une seule chaîne terminée par un nul dans laquelle le nom de l’exécutable et les arguments sont disposés, séparés par des espaces. La limite de longueur est de 32 767 unités de code UTF-16 y compris le caractère nul terminal (le nombre d’éléments wchar_t ; un caractère en paire de substitution tel qu’un emoji en consomme deux par caractère, ne vérifiez donc jamais à l’avance d’après le nombre apparent de caractères), et parce que la version Unicode peut modifier cette chaîne, passer un littéral de chaîne ou un tampon const peut provoquer une violation d’accès.1

Cette chaîne est remise telle quelle au nouveau processus comme partie de ses paramètres de processus, et le processus enfant la récupère avec GetCommandLineW. Parce que le système d’exploitation peut compléter le nom de l’exécutable en tête par le chemin complet, la chaîne que voit l’enfant ne correspond pas nécessairement exactement à celle que le parent a passée.2 Le lpCmdLine passé au WinMain d’une application graphique est cette chaîne privée du nom du programme.9

Le chemin que prennent les arguments pour parvenir au processus enfantLe tableau d'arguments de l'appelant est concaténé en une seule chaîne dans lpCommandLine de CreateProcess et passé au nouveau processus, et le processus enfant découpe avec son propre analyseur la chaîne récupérée par GetCommandLineW pour créer argvLe tableau d'arguments de l'appelantConcaténé en une chaîne (responsabilité de l'appelant)lpCommandLine de CreateProcessWLes paramètres de processus du nouveau processusLa chaîne que GetCommandLineW renvoieL'analyseur du destinataire la découpeLe tableau argv / args

Figure 2 : Le tableau ne franchit pas la frontière. La concaténation est la responsabilité de l’appelant, le découpage celle du destinataire, et le tableau d’origine n’est restauré que lorsque les règles des deux côtés s’accordent.

Le point à retenir ici est que la concaténation et le découpage se produisent dans des processus différents, dans un code différent. L’appelant ne peut pas concaténer correctement sans savoir avec quoi l’autre côté va découper, et le destinataire n’a aucun moyen de savoir comment la chaîne a été concaténée. Sur les systèmes de type Unix, execve accepte le tableau tel quel, aussi ce problème n’existe pas. C’est une prémisse propre à Windows, mais qui accompagne chaque lancement de processus.

3. Qui découpe — trois analyseurs

Côté destinataire, le code qui découpe la chaîne en argv se présente surtout sous trois formes.

Destinataire Code qui découpe Quand il est invoqué
main / wmain en C/C++ Le code de démarrage du runtime C de MSVC Crée argc / argv automatiquement au démarrage du programme4
Code qui utilise l’API Win32 directement CommandLineToArgvW On lui passe la valeur de retour de GetCommandLineW pour la convertir en forme argv3
Main(string[] args) / Environment.GetCommandLineArgs() de .NET (la configuration habituelle lancée via l’apphost ou dotnet.exe) Le code de démarrage du runtime C de l’hôte (apphost / dotnet.exe) Sous Windows l’hôte est un programme wmain ; il prend l’argv que le runtime C a construit, retire ses propres options et le chemin de l’application, et passe le reste au runtime avec le chemin de l’application. Au démarrage le runtime construit un tableau dont le premier élément est le nom du programme (le nom de lancement passé par l’hôte, ou le chemin de l’assembly s’il n’y en a pas) et le conserve pour GetCommandLineArgs(), tandis que Main reçoit dans args seulement les arguments privés du nom du programme10 11 12
Une configuration qui charge le runtime .NET comme bibliothèque hébergée et ne reçoit pas d’arguments de démarrage Le code de découpage propre au runtime .NET (SegmentCommandLine) En repli, GetCommandLineArgs() découpe lui-même la valeur de retour de GetCommandLineW. Il est implémenté pour correspondre aux règles du runtime C et n’utilise pas CommandLineToArgvW, parce que celui-ci « se comporte légèrement autrement »12

Le code de découpage vient en trois lignées, le code de démarrage du runtime C, CommandLineToArgvW, et le code de découpage propre au runtime .NET, et ils implémentent des règles au même squelette, mais ce n’est pas le même code. Une application .NET lancée via l’apphost ou dotnet.exe est en pratique découpée selon les règles de la première lignée (code de démarrage du runtime C), parce que l’hôte lui-même est un programme wmain construit avec le runtime C de MSVC. Le code source du runtime .NET porte encore un commentaire indiquant que CommandLineToArgvW n’est pas utilisé parce qu’il se comporte légèrement autrement.12 Les différences apparaissent aux bords, comme le traitement de "" décrit plus loin, et les arguments du quotidien les rencontrent rarement, mais supposer que « les règles sont les mêmes, donc tout passe » est ce qui casse aux bords.

Les trois analyseurs côté destinataireLa chaîne unique que GetCommandLineW renvoie est découpée par le code de démarrage du runtime C pour C/C++, par CommandLineToArgvW pour un usage Win32 direct, et par le code de découpage propre au runtime pour .NET chargé comme bibliothèque hébergée ; chacun suit des règles au même squelette mais est une implémentation distincte. Une application .NET normale lancée via l'apphost ou dotnet.exe reçoit le tableau découpé par le code de démarrage du runtime C de l'hôteLa chaîne de GetCommandLineWCode de démarrage du runtime CCommandLineToArgvWCode de découpage propre à .NET (chargement par un hôte).NET via apphost / dotnet.exe est le mêmeMême squelette de règles, implémentations distinctes

Figure 3 : Il y a trois lignées de code de découpage. Une application .NET lancée via l’apphost ou dotnet.exe reçoit le tableau découpé par le code de démarrage du runtime C de l’hôte, et le code de découpage propre au runtime est le repli de la configuration en bibliothèque hébergée. Parce que l’on ne peut pas voir de l’extérieur lequel l’exe cible exécute, la réponse pratique est d’assembler une chaîne qui donne le même résultat sur tous.

Notez que args dans Main(string[] args) de .NET n’inclut pas le nom du programme, alors que le premier élément de Environment.GetCommandLineArgs() l’inclut. Ce dernier occupe la même place que argv[0] en C/C++.13 Dans un lancement normal tel que dotnet app.dll x, l’hôte retire les options de l’hôte et le chemin de l’application (dotnet.exe et app.dll), et seul x parvient à args dans Main.14 GetCommandLineArgs(), de son côté, renvoie le tableau auquel le runtime a ajouté en tête le nom du programme au démarrage (le chemin de app.dll suivi de x).11 Le code de découpage propre au runtime ne découpe GetCommandLineW que dans la configuration en bibliothèque hébergée qui ne reçoit pas d’arguments de démarrage ; dans une configuration où un hôte natif passe son propre argc/argv et appelle Main, args dans Main est ce que l’hôte a passé.

4. Les règles de découpage — espaces, guillemets et barres obliques inverses

Voici les règles que partagent les trois analyseurs, pour argv[1] et la suite.3 4

  1. Les arguments sont séparés par des espaces ou des tabulations.
  2. Une zone entourée de guillemets doubles devient un argument même si elle contient des espaces. Les guillemets eux-mêmes ne font pas partie de l’argument. Un guillemet peut commencer au milieu d’un argument, et si la chaîne se termine sans guillemet fermant, tout jusqu’à la fin devient le dernier argument.
  3. Une barre oblique inverse est traitée comme un caractère ordinaire. Seulement lorsqu’un guillemet double la suit immédiatement, les règles suivantes s’appliquent.
  4. Si 2n barres obliques inverses précèdent un guillemet double, n barres obliques inverses sont émises, et le guillemet agit comme « début ou fin du quotage ».
  5. Si 2n+1 barres obliques inverses précèdent un guillemet double, n barres obliques inverses et un guillemet littéral sont émis, et l’état de quotage ne change pas.
  6. L’accent circonflexe (^) n’est pas un caractère d’échappement (c’est une règle de cmd.exe, pas une règle d’analyseur).

L’analyseur conserve un bit d’état, « suis-je entre guillemets », le bascule à chaque guillemet, et lit la chaîne de gauche à droite. Le fait qu’un espace sépare les arguments est décidé par cet état.

Le flux de découpage qui bascule entre l'intérieur et l'extérieur des guillemetsHors guillemets l'analyseur sépare les arguments sur les espaces ; lorsqu'il rencontre un guillemet il entre et traite les espaces comme partie de l'argument ; lorsqu'il rencontre un autre guillemet il revient à l'extérieur. Une barre oblique inverse n'est traitée de façon particulière que lorsqu'un guillemet la suit immédiatementUn guillemet est rencontréUn guillemet est rencontréUne barre oblique inverse est immédiatement suivie d'un guillemetUne barre oblique inverse est immédiatement suivie d'un guillemet2n : émettre n et ouvrir/fermer2n+1 : émettre n et un guillemet littéralHors guillemets : découper sur les espacesEntre guillemets : les espaces font partie de l'argumentAppliquer la règle de la barre oblique inverseBasculer l'état de quotageConserver l'état de quotage

Figure 4 : Le cœur du découpage se décide par un seul bit, « à l’intérieur ou à l’extérieur des guillemets », et le nombre de barres obliques inverses immédiatement avant un guillemet.

Plutôt que de mémoriser les règles en prose, il est plus fiable de regarder la correspondance entre entrée et sortie.

Partie de la ligne de commande (entrée) Arguments résultants Règle en jeu
a b c a, b, c Découper sur les espaces
"a b" c a b, c Une zone quotée n’est pas découpée
C:\data\ next C:\data\, next La barre oblique inverse n’est pas suivie d’un guillemet, c’est donc un caractère ordinaire
"C:\data\\" next C:\data\, next Les deux avant le guillemet deviennent un, et le guillemet ferme
"C:\data\" next C:\data" next Une barre oblique inverse, donc le guillemet devient un guillemet littéral et le quotage ne se ferme jamais, l’argument suivant est englouti
"say \"hi\"" say "hi" Un nombre impair, donc des guillemets littéraux
"" Chaîne vide La seule façon de passer un argument vide
'a b' 'a, b' Les guillemets simples n’ont pas de signification particulière15

La ligne 5 est la véritable identité de « j’ai passé C:\data\ et ça a été fusionné avec l’argument suivant en un seul » de l’ouverture. Dès que vous entourez de guillemets un chemin qui se termine par une barre oblique inverse, le guillemet fermant se transforme en caractère et le quotage ne se ferme jamais.

Comment une barre oblique inverse finale engloutit l'argument suivantLorsqu'un chemin qui se termine par une barre oblique inverse est entouré de guillemets, le guillemet qui devrait fermer se trouve immédiatement après une seule barre oblique inverse et est interprété comme guillemet littéral, si bien que le quotage ne se ferme jamais et que tout jusqu'à l'argument suivant est lu comme un seul argumentDoubler la barre oblique inverseUn chemin quoté se terminant par une barre oblique inverseUn nombre impair de barres obliques inverses précède le guillemet fermantLe guillemet est émis comme caractère et le quotage ne se ferme pasLes espaces suivants ne séparent plusTout jusqu'à l'argument suivant arrive comme un seul argumentLe quotage se ferme et les arguments se séparent

Figure 5 : Pourquoi « doubler la barre oblique inverse finale » est nécessaire. Un quotage écrit sans connaître les règles casse à la fin d’un chemin.

« Deux guillemets consécutifs à l’intérieur du quotage », là où les implémentations divergent

Les règles du runtime C de MSVC ont encore un point : « deux guillemets consécutifs à l’intérieur d’une chaîne quotée sont traités comme un guillemet » (une forme comme "ab""c", qui est une autre affaire que le "" qui représente un argument vide).4 Les règles officielles de CommandLineToArgvW, toutefois, n’ont pas ce point, et le code d’assemblage du runtime .NET évite explicitement de générer cette forme parce que « un guillemet suivant un guillemet fermant est interprété différemment par VC avant et après 2008 ».6

En tant que destinataire, il suffit de savoir qu’une telle entrée peut arriver. En tant qu’assembleur, lorsque vous voulez passer un guillemet comme caractère, n’utilisez que la forme \". Elle donne le même résultat sur chaque analyseur.

5. argv[0] suit une autre règle — lpApplicationName et le problème Program.exe

Le jeton de tête, c’est-à-dire le nom de l’exécutable, est hors des règles précédentes. On suppose que c’est une chaîne valide comme chemin de système de fichiers, aussi on peut l’entourer de guillemets pour y inclure des espaces, mais les règles d’échappement des barres obliques inverses ne s’appliquent pas. Il n’y a pas non plus de moyen d’inclure un guillemet lui-même dans argv[0].4 3 Le code d’assemblage .NET traite aussi le premier élément à part : « l’entourer de guillemets s’il a des espaces, et lever une exception s’il contient un guillemet ».6

Ce qui devient un problème côté appelant, c’est le comportement lorsque lpApplicationName de CreateProcess est NULL. Dans ce cas, le module à exécuter est déduit du jeton de tête délimité par des espaces de lpCommandLine. Lorsque le chemin contient des espaces, plusieurs candidats apparaissent, et le système d’exploitation les essaie en commençant par le plus court.1

L'ordre dans lequel l'exécutable est deviné lorsque lpApplicationName est NULLSi C:\Program Files\MyApp -L -S est passé sans guillemets, CreateProcess teste C:\Program.exe puis C:\Program Files\MyApp.exe dans cet ordre, si bien que C:\Program.exe s'exécute s'il a été placé làExisteN'existe pasPasser lpApplicationName, ou quoter le jeton de têtePasser un chemin non quoté (contenant des espaces) dans lpCommandLineCandidat 1 : essayer C:\Program.exeUn exécutable non voulu démarreCandidat 2 : essayer C:\Program Files\MyApp.exeL'exécutable voulu démarre

Figure 6 : Placer un chemin avec des espaces au début sans guillemets fait essayer au système d’exploitation les candidats du plus court. La documentation officielle appelle cela « dangereux » en termes clairs.

La documentation officielle indique que si C:\Program.exe est placé là, il s’exécute à la place de l’application voulue, et demande de ne pas passer NULL pour lpApplicationName et, si on le fait, d’entourer de guillemets le chemin de tête.1 En pratique, faites les deux. Passez le chemin complet de l’exécutable dans lpApplicationName, et placez aussi le même chemin, entouré de guillemets, au début de lpCommandLine. Lorsque les deux sont passés, le module qui s’exécute est décidé par lpApplicationName, et l’argv[0] du processus enfant devient le jeton de tête de lpCommandLine. À moins de maintenir les deux cohérents par convention, le code qui dérive son propre chemin de argv[0] casse. La façon fiable d’obtenir son propre chemin est GetModuleFileNameW.4

Comment le module exécuté et argv[0] sont décidésLorsque lpApplicationName et lpCommandLine sont tous deux passés, le module qui s'exécute est décidé par lpApplicationName et l'argv[0] de l'enfant est le jeton de tête de lpCommandLine. Le code qui dérive son propre chemin de argv[0] casse lorsque les deux divergent, aussi obtenez votre propre chemin avec GetModuleFileNameWCasse lorsqu'ils divergentUtiliser à la placelpApplicationNameLe module qui s'exécuteLe jeton de tête de lpCommandLineL'argv[0] de l'enfantCode qui dérive son propre chemin de argv[0]GetModuleFileNameW

Figure 7 : « Ce qui s’exécute » et « ce qui va dans argv[0] » sont décidés séparément. Une conception qui dérive son propre chemin de argv[0] ne peut pas tenir sur cette séparation.

Un point de plus : lorsque lpApplicationName est NULL, la partie nom d’exécutable de lpCommandLine est limitée à MAX_PATH.1 Pour le traitement des chemins longs, voir « MAX_PATH et les pièges des chemins et noms de fichiers Windows ».

6. Les règles côté assemblage — une fonction suffit

Une fois les règles de découpage connues, on peut assembler « une chaîne que l’autre côté recouppe en l’original » simplement en les faisant tourner à l’envers. Pour chaque argument à partir de argv[1], faites ce qui suit.6

  1. S’il n’est pas vide et ne contient ni espaces ni guillemets, placez-le tel quel.
  2. Sinon, entourez le tout de guillemets. À l’intérieur du quotage,
    • transformez une suite de k barres obliques inverses immédiatement avant un guillemet en 2k+1 puis placez le guillemet (rendre le nombre impair en fait un « guillemet littéral ») ;
    • transformez une suite de k barres obliques inverses finales en 2k (elles précèdent le guillemet fermant, aussi un nombre pair en fait la « fin du quotage ») ;
    • laissez toute autre barre oblique inverse telle quelle.
  3. Placez une chaîne vide comme "".
Le flux de décision pour assembler un argumentSi l'argument n'est pas vide et ne contient ni espaces ni guillemets, le placer tel quel ; sinon l'entourer de guillemets, rendre les barres obliques inverses avant un guillemet 2k+1 et les barres obliques inverses finales 2k, préfixer les guillemets d'une barre oblique inverse, et fermerNonOuiRecevoir un argumentVide, ou contient un espace ou un guillemet ?Le placer tel quelGuillemet ouvrantParcourir de gauche à droitek barres obliques inverses avant un guillemet → 2k+1k barres obliques inverses finales → 2kTout le reste tel quelGuillemet fermant

Figure 8 : L’assemblage est l’inverse des règles de découpage. Il n’y a que trois branches, et vous n’ajustez le nombre de barres obliques inverses qu’à la fin et immédiatement avant un guillemet ; avec cela, toute chaîne fait l’aller-retour, pourvu que le destinataire découpe les caractères larges avec les mêmes règles de découpage que CommandLineToArgvW, le runtime C et .NET (chapitre 4) (une cible qui interprète la ligne de commande brute avec sa propre grammaire, ou un analyseur de shell intercalé, sort du périmètre), n’a pas activé l’expansion des jokers telle que wsetargv.obj, la chaîne ne contient pas de caractères NUL, et la chaîne assemblée tient dans la limite de lpCommandLine (32 767 unités de code UTF-16 y compris le caractère nul terminal). (La ligne de commande est une chaîne terminée par un nul, aussi un caractère NUL est la seule chose qui ne peut pas être passée par principe. Sur une cible avec l’expansion des jokers activée, un argument contenant * ou ? est remplacé par des noms de fichiers ; voir le chapitre 8. Une chaîne au-delà de la limite est rejetée par CreateProcessW ; voir le chapitre 10.)

Cette règle reflète telle quelle l’asymétrie « une barre oblique inverse n’est particulière que immédiatement avant un guillemet ». Il n’est pas nécessaire de doubler mécaniquement les barres obliques inverses qui séparent les composants de chemin ; le point est que vous ne touchez que celles immédiatement avant un guillemet et à la fin.

7. Implémentation en .NET — ArgumentList et Arguments

ProcessStartInfo dans .NET Core 2.1 et versions ultérieures a ArgumentList, qui prend en charge cet assemblage. Un élément est un argument, les chaînes que vous ajoutez n’ont pas besoin d’échappement à l’avance, et à Process.Start .NET les assemble en interne en une chaîne et la remet au système d’exploitation.5

var psi = new ProcessStartInfo
{
    FileName = @"C:\Program Files\MyTool\convert.exe",
    UseShellExecute = false,
};
psi.ArgumentList.Add("--input");
psi.ArgumentList.Add(inputPath);      // peut contenir des espaces, des barres obliques inverses finales et des guillemets
psi.ArgumentList.Add("--output");
psi.ArgumentList.Add(outputPath);
psi.ArgumentList.Add("--label");
psi.ArgumentList.Add("");             // un argument vide est passé correctement comme ""

using var proc = Process.Start(psi)
    ?? throw new InvalidOperationException("Process.Start a renvoyé null");
proc.WaitForExit();
if (proc.ExitCode != 0)
    throw new InvalidOperationException($"convert.exe a échoué (ExitCode={proc.ExitCode})");

Arguments est une propriété qui passe telle quelle une seule chaîne que vous avez assemblée vous-même. Les deux sont indépendantes, et lorsque vous utilisez l’une, l’autre doit être vide.16 La documentation officielle conseille aussi de choisir ArgumentList si vous n’êtes pas sûr du quotage.5

Où ArgumentList et Arguments deviennent une chaîneAvec ArgumentList, .NET échappe chaque élément et assemble une seule chaîne avant de la passer à CreateProcess ; avec Arguments, la chaîne assemblée par l'appelant est passée telle quelle. Dans les deux cas, ce qui parvient au système d'exploitation est une seule chaîneArgumentList (1 élément = 1 argument).NET échappe chaque élément et concatèneArguments (une seule chaîne assemblée par vous)Telle quelleUne seule chaîne de ligne de commandeCreateProcess

Figure 9 : Quelle que soit celle que vous utilisez, ce qui parvient au système d’exploitation est une seule chaîne. La seule différence est qui l’assemble, et ArgumentList laisse cela au côté qui connaît les règles.

Le code d’assemblage derrière ArgumentList est exactement les règles du chapitre 6. Si l’argument n’est pas vide et ne contient ni espaces ni guillemets, il est placé tel quel ; sinon il est entouré de guillemets, les barres obliques inverses immédiatement avant un guillemet deviennent 2k+1, les barres obliques inverses finales deviennent 2k, et chaque guillemet est toujours préfixé d’une barre oblique inverse. Il ne génère jamais la forme avec des guillemets adjacents à l’intérieur d’un argument non vide. Seul un argument vide est placé comme "", et c’est la forme correcte.6

Sous .NET Framework, assemblez vous-même

ArgumentList est une API introduite dans .NET Core 2.1 et n’existe pas sur ProcessStartInfo sous .NET Framework.5 Dans une application .NET Framework 4.8, ou un outil interne construit dessus, écrivez vous-même les règles du chapitre 6 et passez le résultat à Arguments.

// Pour .NET Framework. Assemble la chaîne unique à passer à ProcessStartInfo.Arguments.
// Les règles sont les mêmes que ProcessStartInfo.ArgumentList utilise en interne.
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 argument ne peut pas contenir un caractère NUL (la ligne de commande est une chaîne terminée par un nul et serait coupée là)");

    bool needsQuote = arg.Length == 0 || arg.Any(c => char.IsWhiteSpace(c) || c == '"');
    if (!needsQuote)
    {
        sb.Append(arg);                       // tel quel
        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 : doublé parce que le guillemet fermant suit
        }
        else if (arg[i] == '"')
        {
            sb.Append('\\', backslashes * 2 + 1).Append('"'); // avant un guillemet : doublé plus un
            i++;
        }
        else
        {
            sb.Append('\\', backslashes).Append(arg[i]);      // tout le reste : tel quel
            i++;
        }
    }
    sb.Append('"');
}

Voici les entrées et les sorties côte à côte.

Valeur à passer Chaîne que AppendArgument émet
strict strict
Chaîne vide ""
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\ (sans espaces) C:\data\

Notez la dernière ligne. Une valeur qui ne contient ni espaces ni guillemets n’est pas entourée, aussi la barre oblique inverse finale sort telle quelle. Sans entourage, les règles 4 et 5 ne se déclenchent jamais, et C:\data\ arrive correctement.

Le choix de la méthode d'assemblage selon la version de .NETSous .NET Core 2.1 ou ultérieur, laisser faire ProcessStartInfo.ArgumentList ; sous .NET Framework, assembler la chaîne Arguments avec une fonction maison suivant les mêmes règles. Dans aucun des deux cas on n'écrit les guillemets à la main par concaténation de chaînesCore 2.1 ou ultérieurFrameworkQuelle version de .NET ?Ajouter à ArgumentList un élément à la foisAssembler Arguments avec une fonction maisonNe jamais écrire les guillemets à la main

Figure 10 : Deux méthodes, un principe. Tenez-vous à « ne jamais écrire les guillemets à la main » et la casse à la fin d’un chemin n’arrive jamais.

Notez qu’avec UseShellExecute = true, le lancement passe par ShellExecuteEx plutôt que par CreateProcess, et le contenu de ArgumentList devient les paramètres passés au shell. Lorsqu’on ouvre un document ou une URL, l’association de fichiers assemble la ligne de commande réelle du gestionnaire, aussi la chaîne assemblée ici n’atteint pas nécessairement la cible telle quelle. Pour les usages où vous redirigez la sortie ou avez besoin du code de sortie de façon fiable, mettez UseShellExecute = false et concevez le code pour lire la sortie standard et l’erreur standard en même temps. Cette partie est traitée dans « Checklist pour gérer les processus enfants en toute sécurité dans une application Windows ».

8. Implémentation en C++ / Win32

En C++ vous écrivez les deux côtés vous-même, assemblage et découpage. Pour l’assemblage, transformez directement les règles du chapitre 6 en une fonction.

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

// Ajoute un argument pour argv[1] et la suite. Les règles sont l'inverse des règles de découpage 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 argument ne peut pas contenir un caractère NUL (la ligne de commande est une chaîne terminée par un nul et serait coupée là)");

    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 : doublé
            break;
        }
        if (arg[i] == L'"') {
            cmd.append(backslashes * 2 + 1, L'\\');       // avant un guillemet : doublé plus un
            cmd += L'"';
        } else {
            cmd.append(backslashes, L'\\');               // tout le reste : tel quel
            cmd += arg[i];
        }
        ++i;
    }
    cmd += L'"';
}

// argv[0] (l'exécutable) suit une autre règle : seulement l'entourer de guillemets s'il a des espaces. Il ne peut pas contenir un guillemet.
std::wstring QuoteArgv0(std::wstring_view exe)
{
    if (exe.find(L'\0') != std::wstring_view::npos)
        throw std::invalid_argument("Le chemin de l'exécutable ne peut pas contenir un caractère NUL (lpApplicationName et la ligne de commande seraient coupés là, et le chemin jusqu'à ce point pourrait être lancé)");
    if (exe.find(L'"') != std::wstring_view::npos)
        throw std::invalid_argument("Le chemin de l'exécutable ne peut pas contenir un guillemet");
    if (exe.empty() || exe.find_first_of(L" \t") != std::wstring_view::npos)
        return L'"' + std::wstring(exe) + L'"';
    return std::wstring(exe);
}

Dans l’appel, passez le chemin complet de l’exécutable dans lpApplicationName et un tampon inscriptible dans lpCommandLine.

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

std::wstring cmd = QuoteArgv0(exe);          // garder argv[0] cohérent avec l'exécutable
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 peut modifier la chaîne

STARTUPINFOW si{}; si.cb = sizeof(si);
PROCESS_INFORMATION pi{};
if (!CreateProcessW(exe.c_str(),             // lpApplicationName : jamais NULL
                    buffer.data(),           // lpCommandLine : commence par le même chemin, quoté
                    nullptr, nullptr, FALSE, CREATE_UNICODE_ENVIRONMENT,
                    nullptr, nullptr, &si, &pi)) {
    const DWORD err = GetLastError();
    // Journaliser err ici et le renvoyer à l'appelant. Ne pas l'avaler
    return;
}
CloseHandle(pi.hThread);                     // le handle du thread principal n'est pas nécessaire, le fermer d'abord

switch (WaitForSingleObject(pi.hProcess, INFINITE)) {   // ajouter un délai d'attente si besoin
case WAIT_OBJECT_0: {                        // il s'est terminé. Lire le code de sortie seulement dans cette branche
    DWORD exitCode = 0;
    if (!GetExitCodeProcess(pi.hProcess, &exitCode)) {
        const DWORD err = GetLastError();
        // Journaliser aussi l'échec de la récupération, et le renvoyer à l'appelant comme un échec
    } else if (exitCode != 0) {
        // La cible a démarré mais son traitement a échoué. Ne pas le traiter comme 0 ;
        // journaliser le code de sortie et le renvoyer à l'appelant (comme le contrôle d'ExitCode dans l'exemple C#)
    }
    break;
}
case WAIT_TIMEOUT:
    // Il tourne encore. Appeler GetExitCodeProcess ici ne renvoie que STILL_ACTIVE (259),
    // ce n'est pas un code de sortie. Cet exemple prend la politique « replier un dépassement de délai en échec » :
    // seulement lorsque la demande de terminaison passe, on le voit se terminer, puis on passe à CloseHandle ci-dessous.
    // Si la politique est de continuer à attendre, ne pas faire break ici et fermer les handles (cela
    // lâcherait l'enfant alors qu'il tourne encore). Revenir à l'attente
    if (!TerminateProcess(pi.hProcess, 1)) {
        const DWORD err = GetLastError();
        // Impossible de le terminer (droits insuffisants, etc.). Attendre ici avec INFINITE rendrait
        // sans objet le délai ajouté pour empêcher les dépassements. Journaliser err et renvoyer un échec à
        // l'appelant sans attendre (l'enfant est lâché alors qu'il tourne encore, journaliser cela aussi)
        break;
    }
    WaitForSingleObject(pi.hProcess, INFINITE); // la demande de terminaison est passée, donc voir la fin avant de fermer
    // Renvoyer le dépassement de délai à l'appelant comme un échec
    break;
default: {                                   // WAIT_FAILED
    const DWORD err = GetLastError();
    // Journaliser aussi l'échec de l'attente elle-même
    break;
}
}
CloseHandle(pi.hProcess);                    // l'oublier fait fuir un handle à chaque lancement
La répartition des rôles des deux arguments passés à CreateProcessWlpApplicationName fixe le module à exécuter, et lpCommandLine décide la chaîne que le processus enfant reçoit via GetCommandLineW. Passer lpCommandLine comme tampon inscriptible et garder l'argv[0] de tête cohérent avec lpApplicationNameGarder cohérentlpApplicationName : le chemin complet de l'exécutableLe module à exécuter est fixélpCommandLine : un tampon inscriptibleLa chaîne que l'enfant reçoit via GetCommandLineWJeton de tête = argv[0]Le reste = arguments assemblés selon les règles du chapitre 6

Figure 11 : « Quoi exécuter » et « quoi passer » sont décidés par des arguments différents. Rendez les deux explicites et ni le problème Program.exe ni la violation d’accès d’un tampon non inscriptible ne se produisent.

Côté destinataire, passez la valeur de retour de GetCommandLineW à CommandLineToArgvW pour l’obtenir sous forme argv. Libérez la valeur de retour avec un seul LocalFree. Il y a des comportements de bord : si lpCmdLine est une chaîne vide, le chemin de l’exécutable courant est renvoyé, et s’il commence par un espace, le premier argument devient une chaîne vide.3

int argc = 0;
LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc);
if (argv == nullptr) {
    const DWORD err = GetLastError();
    // Journaliser aussi l'échec d'analyse
    return 1;
}
for (int i = 0; i < argc; ++i) {
    // argv[0] est le nom de l'exécutable. Le système d'exploitation peut avoir complété le chemin complet
}
LocalFree(argv);

Si vous utilisez main / wmain, le runtime C fait la même chose pour vous au démarrage. Notez toutefois que argv dans main est une chaîne étroite convertie vers la page de codes courante, aussi les caractères que la page de codes ne peut pas représenter (par exemple un chemin japonais sur un PC hors d’un environnement japonais) sont perdus ici. La fonction d’assemblage du chapitre 6 « fait l’aller-retour » vis-à-vis des destinataires qui découpent les caractères larges tels quels, comme wmain, CommandLineToArgvW et .NET. Par défaut les jokers ne sont pas étendus, mais lier setargv.obj (wsetargv.obj pour wmain) fait étendre * et ?.4 Si vous passez un argument contenant * dans un nom de fichier à une cible avec ce réglage, les arguments qui arrivent diffèrent de ce que vous visiez.

9. Lorsque cmd.exe et les fichiers batch s’intercalent

Les règles précédentes s’appliquent lorsque la chaîne va directement de CreateProcess à l’exe cible. Lorsque cmd.exe s’intercale, une étape d’interprétation supplémentaire s’ajoute.

cmd.exe traite &, |, ( et ) comme syntaxe, et pour les passer comme arguments il faut les échapper avec ^ ou les entourer de guillemets. Le traitement des guillemets dans la chaîne qui suit /c ou /k a ses propres règles, et le fait que « les guillemets extérieurs soient retirés » change avec la présence de /s, le nombre de guillemets et la présence de caractères spéciaux.17 De plus, un fichier batch reçoit les arguments non découpés mais comme chaîne brute de ligne de commande. La documentation officielle de PowerShell met clairement en garde contre le passage d’entrées non fiables à des fichiers batch.7 La documentation de CreateProcess dit que pour lancer un fichier batch on spécifie cmd.exe dans lpApplicationName et on passe /c plus le nom du batch, puis note que l’équipe d’ingénierie de MSRC ne recommande pas cela, avec un lien vers l’exposé de MS14-019.1 Ce que MS14-019 a corrigé, c’est le problème que, lorsqu’un fichier batch était passé directement à CreateProcess, cmd.exe était cherché d’abord dans le répertoire courant et pouvait être détourné, et la recommandation de MSRC est de « passer le chemin pleinement qualifié de cmd.exe et de faire du fichier batch son argument ».18 Autrement dit, le problème est de lancer un fichier batch sans nommer cmd.exe par son chemin complet (mettre lpApplicationName à NULL et laisser le nom du batch le démarrer), pas le lancement /c lui-même avec le chemin complet de cmd.exe dans lpApplicationName.

cmd.exe intercalé ajoute des étapes d'interprétationLancer l'exe cible directement signifie que le découpage se fait une fois, dans l'analyseur de la cible, mais passer par cmd.exe /c ajoute l'interprétation syntaxique de cmd.exe, et un fichier batch par-dessus reçoit la chaîne brute, si bien que les règles de quotage changent à chaque étapeVotre processus → l'exe cibleDécouper une fois, seulement par l'analyseur de la cibleVotre processus → cmd.exe /c → l'exe cibleL'interprétation syntaxique de cmd.exe s'ajoute (esperluette, tube, parenthèses, circonflexe)Découpage par l'analyseur de la cibleVotre processus → cmd.exe /c → un fichier batchLe fichier batch reçoit la chaîne bruteY faire transiter des valeurs non fiables devient une injection de commande

Figure 12 : Plus il y a d’étapes, plus les règles se mélangent. Lancez directement ce qui peut l’être, et ne passez jamais à un fichier batch des valeurs venues de l’extérieur.

La décision pratique est simple. Si la cible est un exe, n’intercalez pas cmd.exe. Si vous n’avez d’autre choix que d’appeler un .bat, le principe est de ne pas laisser le batch interpréter des valeurs venues de l’extérieur. Écrivez les valeurs dans un fichier, faites passer au batch seulement le chemin de ce fichier, comme chaîne fixe, à l’exe en aval, et lisez le contenu du fichier côté exe. Mettre la valeur dans une variable d’environnement n’est pas une frontière, parce que dès que le batch l’étend en %VAR%, & et | sont réinterprétés par cmd.exe. Passer par une variable d’environnement n’est acceptable que lorsque l’exe en aval lit la variable directement sans passer par le batch. Si même cela est difficile, déplacez le contenu du batch vers PowerShell ou un exe à vous (« Ce fichier bat, faut-il le migrer vers PowerShell ? »).

10. Limites de longueur

Les limites diffèrent aussi selon le chemin.

Chemin Limite Source
lpCommandLine de CreateProcess 32 767 unités de code UTF-16 (y compris le caractère nul terminal ; une paire de substitution compte pour deux) 1
La partie nom d’exécutable lorsque lpApplicationName est NULL MAX_PATH 1
La ligne de commande de cmd.exe (y compris les lignes dans un fichier batch) 8 191 caractères 8
ProcessStartInfo.Arguments de .NET Longueur de chaîne (unités de code UTF-16) inférieure à 32 699 16

Une conception qui aligne comme arguments des valeurs de longueur variable telles qu’une liste de fichiers atteint la limite le jour où le nombre croît. Pour les usages qui approchent la limite, passez à la méthode « fichier de réponse » : écrire les arguments dans un seul fichier et ne passer que le chemin de ce fichier. La solution officielle pour la limite de cmd.exe est la même méthode.8 Ni CreateProcess ni cmd.exe, toutefois, n’étendent le fichier pour vous. Cette méthode ne fonctionne que si le programme cible peut lire un fichier de réponse avec une syntaxe telle que @file, ou si vous pouvez corriger la cible pour qu’il le puisse. Si la cible est un exe du commerce que vous ne pouvez pas modifier, la seule option est de fractionner les appels pour que chacun tienne dans la limite.

Les limites du passage de valeurs de longueur variable comme arguments, et le contournementAligner comme arguments des valeurs de longueur variable telles qu'une liste de fichiers atteint, à mesure que le nombre croît, la limite de 8191 caractères de cmd.exe ou la limite de 32767 unités de code UTF-16 de CreateProcess. Si la cible peut lire un fichier de réponse (ou peut être corrigée pour), passer à la méthode fichier de réponse qui écrit les valeurs dans un fichier et ne passe que le chemin ; si la cible est un exe du commerce qui ne le peut pas, fractionner les appelsLa cible peut lire un fichier de réponseUn exe du commerce qui ne le peut pasAligner comme arguments des valeurs de longueur variable (une liste de fichiers, etc.)La chaîne s'allonge à mesure que le nombre croîtLa limite est atteinte (cmd.exe 8 191 / CreateProcess 32 767)Un jour le lancement échoue soudainÉcrire les valeurs dans un fichier et ne passer que le chemin (fichier de réponse)Fractionner les appels

Figure 13 : La limite est le genre de problème qui est « bon aujourd’hui ». Pour les arguments qui croissent proportionnellement au nombre, si la cible peut lire un fichier de réponse (ou peut être corrigée pour), faites-le dès le départ.

11. Vérifier ce qui est réellement arrivé

Avant d’ajouter du quotage par hypothèse, le chemin le plus court est de regarder les arguments qui ont atteint la cible. Il y a trois choses à regarder, « la chaîne assemblée côté appelant », « la chaîne qui a atteint le côté cible », et « le tableau après découpage », et quatre moyens de le faire. Avant cela, une promesse. Quel que soit le moyen, masquez les secrets avant d’enregistrer une ligne de commande dans un journal. Si la conception met des mots de passe, des clés d’API ou des jetons dans les arguments, les écrire tels quels laisse les secrets dans le journal, qu’il s’agisse du journal de l’appelant ou du journal de démarrage de la cible. Les journaux sont conservés plus longtemps que le processus et vus par plus de personnes. D’ailleurs une ligne de commande peut être lue par d’autres processus sur la même machine, comme avec Process Explorer décrit plus loin, aussi la contre-mesure fondamentale est une conception qui passe les mots de passe et les jetons non comme arguments mais par une autre voie telle que l’entrée standard ou un magasin de configuration protégé ; le masquage dans les journaux est une sauvegarde par-dessus. Soit interprétez les arguments découpés (côté appelant, les éléments avant assemblage) et masquez les valeurs d’options qui pourraient être secrètes avant d’enregistrer, soit n’activez l’enregistrement de la chaîne brute que dans un mode de diagnostic restreint.

  1. Côté appelant, journalisez la chaîne que vous avez assemblée. C’est le lpCommandLine immédiatement avant qu’il soit passé à CreateProcess. Cette comparaison suppose un lancement avec UseShellExecute = false ou un appel direct à CreateProcess. Lorsque vous ouvrez un document ou une URL avec UseShellExecute = true, l’association de fichiers assemble la ligne de commande réelle via ShellExecuteEx (chapitre 7), aussi la chaîne de l’appelant et celle de la cible diffèrent même sans cmd.exe ni fichier batch, et ce n’est pas le problème du chapitre 9. Si vous utilisez ArgumentList de .NET, enregistrer la liste des éléments telle quelle ne peut pas servir à la comparaison. Les éléments sont les valeurs avant quotage et avant le doublement des barres obliques inverses finales, et ce qui parvient au système d’exploitation est la chaîne que .NET a formatée à partir d’eux. Soit reconstruisez une seule chaîne à partir des éléments avec les mêmes règles que BuildArguments au chapitre 7 et enregistrez-la (cela donne le même résultat que le formatage que ArgumentList fait en interne), soit comparez la liste des éléments directement au tableau après découpage. C’est le seul moyen de voir « le tampon d’origine de l’appelant » ; Process Explorer et le journal de la cible, décrits plus bas, ne montrent que la chaîne qu’une étape intercalée de cmd.exe ou d’un batch a reconstruite. Lors de l’enregistrement, tenez la promesse de l’ouverture et masquez les valeurs d’éléments qui pourraient être secrètes (un élément masqué ne correspond plus à la chaîne de la cible, aussi excluez cet élément de la comparaison).
  2. Préparez un exe qui n’affiche que ses arguments. Lancez-le à la place de l’exe cible et faites-lui imprimer les args reçus, un par ligne. Si vous écrivez les valeurs telles quelles, un argument contenant des sauts de ligne ou des caractères de contrôle peut apparaître comme plusieurs lignes ou écraser les lignes voisines et vous vous trompez de compte, aussi imprimez chaque valeur échappée comme chaîne JSON avec sa longueur (l’échappement est réversible, aussi la valeur d’origine peut être recouvrée). Souvenez-vous toutefois que, comme l’explique le chapitre 3, il y a trois lignées d’analyseurs, et elles interprètent différemment des formes de bord telles que deux guillemets consécutifs à l’intérieur du quotage. Utilisez un exe d’affichage construit avec le même runtime que la cible (C++ avec wmain si la cible est du C/C++ MSVC, .NET si c’est .NET). Si la cible est votre propre programme, l’approche la plus fiable est de sauter l’exe d’affichage et de journaliser argv au démarrage de la cible elle-même (sous la règle de masquage du point suivant). Pour .NET, les quelques lignes suivantes suffisent.
using System.Text.Encodings.Web;
using System.Text.Json;

// Échapper les sauts de ligne, caractères de contrôle, guillemets et barres obliques inverses ; émettre le texte japonais tel quel
var json = new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };

Console.WriteLine("CommandLine: " + JsonSerializer.Serialize(Environment.CommandLine, json)); // la chaîne unique
for (int i = 0; i < args.Length; i++)
    Console.WriteLine($"[{i}] len={args[i].Length} {JsonSerializer.Serialize(args[i], json)}");
    // Après découpage. Chaque entrée tient toujours sur une ligne, et une chaîne vide s'affiche comme len=0 et "". len est en unités de code UTF-16
  1. Regardez la ligne de commande du processus enfant dans Process Explorer. Les propriétés du processus montrent la chaîne de ligne de commande que le processus enfant détient. C’est un moyen de vérifier « la chaîne qui a atteint le côté cible » ; cela ne vous dit pas « le tableau après découpage ». Ce qui est montré est la chaîne détenue côté processus enfant, aussi, comme abordé au chapitre 2, le système d’exploitation peut avoir complété le nom de l’exécutable en tête par le chemin complet, et si cmd.exe ou un fichier batch est intercalé, ce que vous voyez est la chaîne reconstruite par cmd.exe. Les points clés sont de ne pas s’alarmer d’une différence sur le seul jeton de tête, et que la chaîne d’origine de l’appelant ne peut être connue que d’après le journal du point 1. L’usage est traité dans « Process Explorer / Handle / VMMap en pratique ».
  2. Au démarrage de votre propre application, journalisez la ligne de commande qu’elle a reçue. Lorsque quelqu’un sur le terrain dit « ça ne démarre pas », avoir un enregistrement de la chaîne avec laquelle elle a été lancée permet d’isoler d’abord s’il s’agit d’un problème d’arguments. Ici aussi, ne sauvegardez pas telle quelle la valeur de retour de GetCommandLineW. Tenez la promesse de l’ouverture : soit interprétez les arguments découpés et masquez les valeurs qui pourraient être secrètes avant d’enregistrer, soit n’activez l’enregistrement de la chaîne brute que dans un mode de diagnostic restreint.

L’ordre de comparaison est le suivant. Comparez d’abord la chaîne de l’appelant (point 1) avec la chaîne de la cible (point 3 ou 4). Si elles ne correspondent pas hormis le nom de l’exécutable en tête, une étape intercalée l’a transformée : cmd.exe ou un fichier batch pour un lancement direct (chapitre 9), ou l’association de fichiers du shell pour UseShellExecute = true (chapitre 7). Remplacer votre code par la fonction du chapitre 6 ne corrigera pas cela. Si elles correspondent, comparez cette chaîne avec le tableau après découpage (point 2). Si elle est découpée selon les règles mais n’est pas le tableau voulu, le problème est côté assemblage ; si elle n’est pas découpée selon les règles, le problème est l’analyseur du destinataire.

L'ordre pour isoler un problème d'argumentsComparer d'abord le journal de la chaîne assemblée côté appelant avec la chaîne de la cible vue dans Process Explorer ou le journal de démarrage de la cible. Si elles ne correspondent pas hormis le nom de l'exécutable en tête, une étape intercalée (cmd.exe ou un fichier batch pour un lancement direct, l'association de fichiers du shell pour UseShellExecute=true) l'a transformée. Si elles correspondent, comparer avec le tableau après découpage ; s'il est découpé selon les règles mais n'est pas le tableau voulu, le problème est côté assemblage, et s'il n'est pas découpé selon les règles, le problème est l'analyseur du destinataireNonOuiOui : découpé, mais pas le tableau vouluNon : pas découpé selon les règlesLes arguments sont incorrectsRegarder la chaîne assemblée côté appelant (journal de l'appelant)Regarder la chaîne de la cible (Process Explorer / journal de démarrage de la cible)Correspondent-elles hormis le nom de l'exécutable en tête ?Une étape intercalée l'a transformée (voir les chapitres 9 et 7)Regarder le tableau après découpage (un exe d'affichage sur le même runtime que la cible)La chaîne et le tableau correspondent-ils selon les règles ?Un problème côté assemblage : remplacer par la fonction du chapitre 6Un problème dans l'analyseur du destinataire

Figure 14 : Comparez dans l’ordre les trois choses « la chaîne de l’appelant », « la chaîne de la cible » et « le tableau », et il se décide mécaniquement si la responsabilité est à une étape intercalée, côté assemblage ou côté destinataire. Ajouter des échappements par hypothèse peut attendre après cette vérification.

12. Un guide approximatif (table de décision)

Situation Que faire
Lancer un exe depuis .NET Core 2.1 ou ultérieur / .NET 5 ou ultérieur Ajouter à ProcessStartInfo.ArgumentList un élément à la fois
Lancer un exe depuis .NET Framework Assembler Arguments avec une fonction suivant les règles du chapitre 6. Ne jamais écrire les guillemets à la main
Lancer depuis C++ Passer lpApplicationName, et assembler lpCommandLine selon les règles dans un tampon inscriptible
Vous voulez un guillemet dans une valeur d’argument N’utiliser que la forme \". Ne jamais placer de guillemets adjacents à l’intérieur d’un argument non vide
Le chemin se termine par une barre oblique inverse Si vous entourez, doublez la barre oblique inverse finale. S’il n’y a pas d’espaces, ne l’entourez pas
Vous voulez passer un argument vide Mettre "". Si vous l’omettez, l’argument entier disparaît
Le chemin de l’exécutable contient un espace Passer lpApplicationName, et entourer aussi de guillemets le jeton de tête
Vous n’avez d’autre choix que d’appeler un .bat Ne pas laisser le batch interpréter des valeurs venues de l’extérieur. Les écrire dans un fichier et les faire lire par l’exe en aval (une variable d’environnement étendue en %VAR% dans le batch n’est pas une frontière)
Les arguments s’allongent Si la cible peut lire un fichier de réponse (ou peut être corrigée pour), passer à un fichier de réponse. Pour un exe du commerce, fractionner les appels
Vous ne savez pas ce qui arrive Comparer les trois dans l’ordre : le journal de l’appelant, la chaîne de la cible (Process Explorer / journal de démarrage), et le tableau après découpage (un exe d’affichage sur le même runtime que la cible)

13. Résumé

Les arguments de ligne de commande Windows franchissent la frontière non comme un tableau mais comme une seule chaîne. L’appelant concatène, le destinataire découpe, et les règles de découpage se ramènent à trois : « découper sur les espaces », « entourer de guillemets » et « seules les barres obliques inverses immédiatement avant un guillemet sont particulières ». Seul le nom de l’exécutable en tête suit une autre règle, et omettre lpApplicationName rend ambiguë l’interprétation d’un chemin contenant des espaces.

Ce que le côté assemblage a à faire tient dans une fonction, et sous .NET Core 2.1 ou ultérieur ArgumentList s’en charge. Pour l’exécutable, passez le chemin complet dans lpApplicationName et placez aussi le même chemin, entouré de guillemets, au début de lpCommandLine (en .NET, laissez faire FileName). Ne générez jamais la forme avec des guillemets adjacents à l’intérieur d’un argument non vide (le "" qui représente un argument vide est autre chose), ne faites jamais transiter par cmd.exe ou un fichier batch des valeurs venues de l’extérieur, et pour les arguments qui croissent proportionnellement au nombre, n’utilisez un fichier de réponse que lorsque la cible peut en lire un (ou peut être corrigée pour), sinon fractionnez les appels. Tenez ces cinq points et les échecs « ça ne démarre que sur un PC avec un espace dans le chemin » et « l’argument suivant disparaît à cause d’une barre oblique inverse finale » n’arrivent jamais.

Cinq promesses qui empêchent les échecs d'argumentsPasser le chemin complet de l'exécutable dans lpApplicationName et entourer aussi de guillemets le jeton de tête, laisser le quotage à une fonction qui suit les règles ou à ArgumentList, ne jamais générer la forme avec des guillemets adjacents à l'intérieur d'un argument non vide, ne jamais faire transiter par cmd.exe ou un fichier batch des valeurs de l'extérieur, et n'utiliser un fichier de réponse pour les arguments qui croissent avec le nombre que lorsque la cible peut en lire un. En supposant que la cible interprète selon les règles de découpage publiées et n'a pas activé l'expansion des jokers, ces cinq points empêchent les échecs causés par les chemins avec espaces et les barres obliques inverses finalesPasser le chemin complet dans lpApplicationName et quoter aussi le jeton de têteLaisser le quotage à une fonction conforme aux règles ou à ArgumentListNe jamais générer de guillemets adjacents à l'intérieur du quotageNe jamais faire transiter par cmd.exe ou un fichier batch des valeurs de l'extérieurUtiliser un fichier de réponse pour les arguments qui croissent (lorsque la cible peut en lire un)Pas d'échecs dus aux espaces ou aux barres obliques inverses finales

Figure 15 : Chacune des cinq promesses est une reformulation de « fixer le module à exécuter, et ne passer que des chaînes que l’analyseur de la cible peut découper ». La prémisse est que la cible interprète selon les règles de découpage publiées et n’a pas activé l’expansion des jokers (chapitres 6 et 8) ; par-dessus, ces cinq points empêchent les échecs causés par les espaces et les barres obliques inverses finales.

Lorsque ça ne marche pas, avant d’ajouter des échappements par hypothèse, regardez les trois choses : la chaîne assemblée côté appelant, la chaîne qui a atteint le côté cible, et le tableau après découpage. Si les chaînes de l’appelant et de la cible diffèrent, une étape intercalée est responsable (cmd.exe ou un fichier batch, ou l’association de fichiers du shell pour UseShellExecute = true) ; si elles sont les mêmes, la correspondance entre la chaîne et le tableau décide si c’est le côté assemblage ou le côté destinataire.

Articles connexes

Domaines de conseil associés

KomuraSoft LLC prend en charge la conception d’applications Windows qui combinent des outils externes et des EXE internes, l’investigation des causes de lancements de processus enfants qui « démarrent dans certains environnements et pas dans d’autres », et la revue du code de lancement de processus dans le cadre d’une migration de .NET Framework vers .NET. N’hésitez pas à nous contacter même pour un seul cas de « les arguments se déforment ».

Références

  1. Microsoft Learn, CreateProcessW function (processthreadsapi.h). Sur le fait que lpCommandLine est une seule chaîne d’au plus 32 767 caractères (y compris le caractère nul terminal ; unités de code UTF-16, puisqu’il s’agit d’une chaîne large), que la version Unicode peut modifier son contenu de sorte que de la mémoire en lecture seule ne peut pas être passée, que le jeton de tête délimité par des espaces devient le nom du module lorsque lpApplicationName est NULL avec un chemin contenant des espaces interprété à partir de c:\program.exe, le danger qu’un autre exécutable s’exécute si Program.exe est placé là et la nécessité d’éviter NULL ou d’entourer le chemin de guillemets, le fait que argv[0] peut ne pas correspondre au nom du module lorsque les deux sont spécifiés, que la partie nom de module est limitée à MAX_PATH lorsque NULL, et que cmd.exe /c est requis pour lancer un fichier batch. Voir aussi la note dans CreateProcessA function indiquant que l’équipe d’ingénierie de MSRC ne recommande pas cette méthode (avec un lien vers l’exposé de MS14-019).  2 3 4 5 6 7 8 9 10

  2. Microsoft Learn, GetCommandLineW function (processenv.h). Sur le fait qu’elle renvoie la chaîne de ligne de commande du processus courant, que la valeur de retour ne doit pas être libérée ni modifiée, qu’elle est convertible en forme argv via CommandLineToArgvW, et qu’elle peut ne pas correspondre à la chaîne que le parent a passée à CreateProcess parce que le système d’exploitation complète le chemin complet du nom de l’exécutable.  2

  3. Microsoft Learn, CommandLineToArgvW function (shellapi.h). Sur le traitement particulier des barres obliques inverses immédiatement avant un guillemet double (2n donne n plus l’ouverture ou la fermeture du quotage, 2n+1 donne n plus un guillemet littéral, et elles restent telles quelles lorsqu’aucun guillemet ne suit), le fait que les espaces deviennent partie de l’argument en mode « entre guillemets », que le nom de programme de tête est autorisé avec ou sans guillemets, que le premier argument devient une chaîne vide lorsque lpCmdLine commence par un espace, que le chemin de l’exécutable courant est renvoyé lorsqu’une chaîne vide est passée, et que la valeur de retour est libérée avec un seul LocalFree 2 3 4 5

  4. Microsoft Learn, main function and command-line arguments. Sur les règles selon lesquelles le code de démarrage Microsoft C/C++ interprète la ligne de commande (séparation par espaces et tabulations, argv[0] quotable mais non soumis aux règles suivantes, une chaîne quotée étant un argument, l’accent circonflexe n’étant pas un caractère d’échappement, deux guillemets consécutifs à l’intérieur des guillemets étant un guillemet, tout jusqu’à la fin étant le dernier argument lorsqu’il n’y a pas de guillemet fermant, et le traitement des nombres pairs et impairs de barres obliques inverses), le tableau des entrées et de argv, l’expansion des jokers avec setargv.obj, et le fait que argv[0] peut ne pas être le nom de l’exécutable lorsque lpApplicationName et lpCommandLine sont tous deux spécifiés, de sorte qu’il convient de l’obtenir avec GetModuleFileName 2 3 4 5 6 7 8

  5. Microsoft Learn, ProcessStartInfo.ArgumentList Property. Sur le fait que les chaînes ajoutées n’ont pas besoin d’échappement à l’avance, que ArgumentList et Arguments sont indépendantes et inutilisables en même temps, que ArgumentList échappe les arguments et assemble en interne une seule chaîne passée au système d’exploitation à Process.Start, que ArgumentList est le choix si vous n’êtes pas sûr du quotage, le danger de le combiner avec des données non fiables, et le fait qu’il s’applique à .NET Core 2.1 et versions ultérieures.  2 3 4

  6. dotnet/runtime (GitHub), PasteArguments.cs et PasteArguments.Windows.cs. Le code d’assemblage utilisé à l’intérieur de ArgumentList. Sur le fait de placer tel quel un argument non vide sans espaces ni guillemets, sinon de l’entourer de guillemets, de doubler les barres obliques inverses finales, de rendre les barres obliques inverses avant un guillemet doublées plus un, de toujours préfixer les guillemets d’une barre oblique inverse, de ne pas générer la forme d’un guillemet suivant un guillemet fermant parce que VC avant et après 2008 l’interprètent différemment, et pour argv[0] de seulement l’entourer de guillemets s’il a des espaces et de lever une exception s’il contient un guillemet.  2 3 4 5 6

  7. Microsoft Learn, about_Parsing. Sur le fait que les arguments d’un fichier batch sont passés à cmd.exe comme chaîne brute de ligne de commande, et l’avertissement contre le passage d’entrées non fiables.  2

  8. Microsoft Learn, Command prompt (Cmd.exe) command-line string limitation. Sur le fait que la longueur maximale d’une chaîne utilisable à l’invite de commandes est de 8 191 caractères, que cela s’applique aussi aux lignes de commande à l’intérieur des fichiers batch, et la solution de contournement consistant à écrire les arguments dans un fichier et à passer ce nom de fichier.  2 3

  9. Microsoft Learn, WinMain function (winbase.h). Sur le fait que lpCmdLine est la ligne de commande sans le nom du programme, que la ligne de commande entière s’obtient avec GetCommandLine, et que wWinMain existe comme point d’entrée Unicode. 

  10. dotnet/runtime (GitHub), apphost.c et dotnet.cpp. Sur le fait que les points d’entrée de l’apphost et de dotnet.exe sont wmain(int argc, wchar_t* argv[]) sous Windows et passent l’argv construit par le runtime C directement au traitement de démarrage de l’hôte. 

  11. dotnet/runtime (GitHub), corhost.cpp. Sur le fait que ExecuteAssembly construit le tableau de Environment.GetCommandLineArgs() avec SetCommandLineArgs(pwzAssemblyPath, argc, argv), que le premier élément est le nom de lancement passé par l’hôte (ou le chemin de l’assembly s’il n’y en a pas) suivi de argv, et que seul cet argv est passé à Main 2

  12. dotnet/runtime (GitHub), Environment.cs et Environment.Windows.cs. Sur le fait que GetCommandLineArgs renvoie le tableau initialisé au démarrage (s_commandLineArgs), qu’une bibliothèque hébergée sans lui se rabat sur le découpage de la valeur de retour de GetCommandLineW avec SegmentCommandLine propre au runtime, que ces règles suivent la documentation de la fonction main de MSVC, et que CommandLineToArgvW n’est pas utilisé parce que son comportement diffère légèrement.  2 3

  13. Microsoft Learn, Main() and command-line arguments. Sur le fait que args dans Main n’est jamais null et que, contrairement au C/C++, le nom du programme n’est pas inclus au début de args mais est le premier élément de GetCommandLineArgs()

  14. Microsoft Learn, dotnet command. Sur le fait que l’exécution d’une application prend la forme dotnet [runtime options] <app path> [arguments], où tout ce qui suit le chemin de l’application sont les arguments passés à l’application. 

  15. Microsoft Learn, Environment.GetCommandLineArgs Method. Sur le fait que le premier élément est le nom de l’exécutable, que les arguments sont séparés par des espaces avec des guillemets doubles permettant des espaces à l’intérieur, que les guillemets simples n’ont pas cette fonction, les règles pour les nombres pairs et impairs de barres obliques inverses et de guillemets, et le tableau des entrées et des résultats. 

  16. Microsoft Learn, ProcessStartInfo.Arguments Property. Sur le fait que la longueur de chaîne est inférieure à 32 699, que les arguments sont interprétés par l’application cible et doivent donc correspondre à ses attentes, que les guillemets eux-mêmes ne sont pas passés à la cible lorsqu’un argument contenant des espaces est quoté, et son indépendance vis-à-vis de ArgumentList 2

  17. Microsoft Learn, cmd. Sur le fait que &, | et ( ) sont des caractères spéciaux qui exigent ^ ou des guillemets, la liste des caractères spéciaux qui devraient être entourés de guillemets, les conditions sous lesquelles les guillemets sont conservés avec /c ou /k (pas de /s, exactement une paire de guillemets, pas de caractères spéciaux, contenant des espaces, et étant un nom d’exécutable), et comment le guillemet de tête est retiré lorsque les conditions ne sont pas remplies. 

  18. Microsoft Security Response Center, MS14-019 – Fixing a binary hijacking via .cmd or .bat file et Microsoft Security Bulletin MS14-019. Sur le fait que CreateProcess cherchait cmd.exe d’abord dans le répertoire courant lorsqu’un .cmd / .bat était passé directement, ce qui permettait un détournement, que la correction utilise toujours le cmd.exe du système, et la recommandation que les applications passent le chemin pleinement qualifié de cmd.exe avec le fichier batch comme argument. 

Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Cet article est directement lié aux services suivants.

Questions fréquentes

Questions souvent posées lors d’une consultation sur le sujet de cet article.

N'existe-t-il pas d'API Windows qui passe un tableau d'arguments ?
Non. Ce que CreateProcess reçoit est une seule chaîne appelée lpCommandLine, et c'est cette chaîne qui parvient au nouveau processus (le système d'exploitation peut seulement compléter le nom de l'exécutable en tête par le chemin complet). Ce qui ressemble à un tableau argv est créé dans le processus destinataire par le code de démarrage du runtime C, CommandLineToArgvW ou le runtime .NET qui découpe la chaîne. Passer des arguments, c'est donc la même chose qu'assembler une chaîne que l'analyseur de l'autre côté recouppe en morceaux d'origine.
Quand une barre oblique inverse devient-elle un caractère d'échappement ?
Seulement lorsqu'un guillemet double la suit immédiatement. Une barre oblique inverse qui n'est pas suivie d'un guillemet double reste telle quelle, quel que soit le nombre de répétitions. Si 2n barres obliques inverses précèdent un guillemet double, elles deviennent n barres obliques inverses et le guillemet ouvre ou ferme le quotage ; si 2n+1 le précèdent, elles deviennent n barres obliques inverses et un guillemet littéral. À cause de cette asymétrie, il ne faut doubler une barre oblique inverse finale dans un chemin que lorsque l'on entoure le chemin de guillemets.
Faut-il utiliser ProcessStartInfo.ArgumentList ou Arguments ?
Si les valeurs viennent de variables, ArgumentList. Un élément devient un argument, .NET applique les guillemets et l'échappement nécessaires, et il assemble en interne une seule chaîne avant de la remettre au système d'exploitation. Arguments est une propriété qui passe telle quelle une chaîne que vous avez assemblée vous-même ; les deux sont indépendantes et ne peuvent pas être utilisées en même temps. Notez qu'ArgumentList est une API introduite dans .NET Core 2.1 et n'existe pas sous .NET Framework. Sous .NET Framework, assemblez Arguments avec la fonction d'assemblage de cet article.
Peut-on écrire deux guillemets adjacents à l'intérieur d'un argument quoté ?
Ne la générez pas côté assemblage, parce que les destinataires l'interprètent différemment. Il s'agit ici d'entourer de guillemets un argument non vide et d'y placer deux guillemets adjacents. Le "" qui représente un argument vide (seulement deux guillemets) est autre chose, et c'est la façon correcte de passer une chaîne vide. Selon les règles du runtime C de MSVC, deux guillemets consécutifs à l'intérieur d'une chaîne quotée sont traités comme un seul guillemet, mais les règles officielles de CommandLineToArgvW ne décrivent pas ce traitement, et le code source du runtime .NET dit explicitement qu'il ne génère pas cette forme parce que VC avant et après 2008 l'interprètent différemment. Lorsque vous voulez passer un guillemet comme caractère, placez une barre oblique inverse devant, et chaque analyseur donne le même résultat.
Lorsque le chemin de l'exécutable contient un espace, que faut-il passer à CreateProcess pour être en sécurité ?
La méthode fiable est de passer le chemin complet de l'exécutable dans lpApplicationName et de placer aussi le même chemin, entouré de guillemets, au début de lpCommandLine. Si lpApplicationName est NULL, CreateProcess devine le nom de l'exécutable à partir du début de lpCommandLine, en découpant sur les espaces. Pour la chaîne C:\Program Files\MyApp -L -S, il teste d'abord si C:\Program.exe existe, si bien qu'un fichier malveillant à cet emplacement s'exécute. La documentation officielle énonce explicitement ce danger et demande d'éviter NULL ou d'entourer le chemin de guillemets.
Les mêmes règles s'appliquent-elles lorsqu'on passe des arguments à un fichier batch ?
Non. Un fichier batch est interprété par cmd.exe, et cmd.exe traite la ligne de commande comme une chaîne brute sans la découper en arguments. Des symboles tels que &, |, les parenthèses et ^ agissent comme syntaxe de cmd.exe, si bien que le quotage selon les règles de CommandLineToArgvW ne les rend pas sûrs. La documentation officielle met en garde contre le passage d'entrées non fiables à des fichiers batch. Écrivez les valeurs dans un fichier et faites-les lire par l'exe en aval, pas par le batch, ou déplacez le contenu du batch vers PowerShell ou un exe à vous. Mettre la valeur dans une variable d'environnement n'est pas non plus une frontière, parce que dès que le batch l'étend en %VAR%, cmd.exe réinterprète les symboles.

Profil de l’auteur

Page de présentation de l’auteur de l’article.

Go Komura

Représentant de KomuraSoft LLC

Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.

Retour au blog