למה arguments נשברים — כללי command-line arguments ב-Windows

· עודכן בתאריך: · · Windows, פיתוח Windows, C#, C++, Win32 API, .NET, Process

היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 2 Sep 2026)
פרסום ראשון

“עבר בבדיקות, אבל הכלי החיצוני לא עולה במחשב שנתיב שלו מכיל רווח.” “העברתי C:\data\ והוא מוזג עם ה-argument הבא לאחד.” “העברתי JSON כ-argument, ה-quotes נעלמו, והצד השני נכשל ב-parse.” אלה כשלים שחוזרים בקוד שמפעיל child processes. רובם נגרמים לא מלוגיקה אלא מקוד שנכתב בלי ההנחה ש-ל-Windows אין מנגנון להעברת “מערך של arguments”.

מה ש-CreateProcess, הפונקציה שיוצרת process ב-Windows, מקבל הוא מחרוזת אחת שנקראת lpCommandLine. לא משנה כמה בזהירות הקורא מכין מערך, הוא תמיד מחובר למחרוזת אחת כשהוא חוצה את גבול מערכת ההפעלה, וצד הקבלה מפצל אותה שוב. כללי הפיצול נקבעים על ידי ה-runtime של צד הקבלה, וה-C runtime, CommandLineToArgvW, ה-runtime של .NET, ו-cmd.exe הם כל אחד קוד נפרד. העברת arguments פירושה בניית מחרוזת שה-parser של הצד השני יפצל בחזרה לחתיכות המקוריות.

המאמר הזה לוקח את עמדת ההפעלה של child processes מ-קוד Win32 ו-.NET, לא מסקריפטי PowerShell, ומניח איפה המחרוזת מחוברת, איפה היא מפוצלת, ואילו כללים חלים. צד PowerShell (השינוי בהעברת arguments ב-7.3, --%, $PSNativeCommandArgumentPassing) מכוסה ב-“קריאה נכונה ל-EXEs חיצוניים מ-PowerShell”, כך שהמאמר הזה חופר בשכבה שמתחת.

השכבה שהמאמר הזה מכסההעברת arguments ב-PowerShell מכוסה במאמר נפרד; המאמר הזה מכסה את השכבה שמתחת, מ-CreateProcess של Win32 ומ-ProcessStartInfo של .NET עד ה-parser של ה-exe היעדהיקף המאמר הזההעברת arguments ב-PowerShell (מאמר נפרד)ProcessStartInfo של .NETCreateProcessW של Win32מחרוזת command-line אחתה-parser של ה-exe היעד

איור 1: מתחת ל-PowerShell יושבות שכבות .NET ו-Win32, ומאיזו מהן שמפעילים, התוצאה היא מחרוזת אחת. המאמר הזה מכסה את כללי השכבה הזו.

1. קודם המסקנות

  • process ב-Windows אף פעם לא מקבל מערך של arguments. המחרוזת האחת שמועברת ל-CreateProcess מגיעה ל-process החדש (מערכת ההפעלה עשויה להשלים את הנתיב המלא לשם ה-exe המוביל בלבד), ו-GetCommandLineW מחזיר אותה. argv הוא משהו שצד הקבלה יוצר בעצמו.1 2
  • ליבת כללי הפיצול היא שלושה פריטים: פיצול ברווחים וב-tabs, לא לפצל בתוך אזור ב-double quotes, ו-backslash מיוחד רק כש-double quote בא מיד אחריו (2n backslashes הופכים ל-n ועוד ה-quote פותח או סוגר quoting; 2n+1 הופכים ל-n ועוד quote מילולי).3 4
  • רק הטוקן המוביל (argv[0], שם ה-exe) עוקב אחרי כלל אחר: אפשר לעטוף ב-quotes, אבל escaping של backslash לא חל. אם lpApplicationName הוא NULL, הפרשנות של נתיב שמכיל רווחים הופכת לדו-משמעית ו-C:\Program.exe נבדק ראשון.1 4
  • בצד הבניה, כלל אחד מספיק: “אם ה-argument מכיל רווח או quote, או שהוא ריק, עוטפים ב-quotes, מכפילים את ה-backslashes שלפני quote ואת ה-backslashes בסוף, וכותבים quotes כ-\".” ProcessStartInfo.ArgumentList ב-.NET Core 2.1 ואילך עושה את זה בשבילכם.5 6
  • לא מייצרים את הצורה ששמה שני quotes סמוכים בתוך argument שאינו ריק (משהו כמו "ab""c"), כי מקבלים מפרשים אותה אחרת. ה-"" שמייצג argument ריק הוא דבר אחר והוא נכון. cmd.exe וקבצי batch יושבים מחוץ לכללים האלה, כך ש-לא מעבירים ערכים לא מהימנים דרכם.6 7
  • המגבלות הן 32,767 יחידות קוד UTF-16 ל-lpCommandLine (כולל ה-null הסוגר; תווים שהם surrogate pairs, כמו emoji, נספרים כשניים) ו-8,191 תווים ל-cmd.exe. אם סביר שתחרגו, עוברים ל-response file, אבל רק כשהיעד יכול לקרוא אחד (או אפשר לתקן אותו שיקרא) בתחביר כמו @file.1 8

ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 28, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle

2. אין מערך arguments — CreateProcess והמחרוזת האחת

הפרמטר השני של CreateProcessW, lpCommandLine, הוא מחרוזת אחת שמסתיימת ב-null שבה שם ה-exe וה-arguments מונחים מופרדים ברווחים. מגבלת האורך היא 32,767 יחידות קוד UTF-16 כולל ה-null הסוגר (מספר האלמנטים של wchar_t; תו surrogate-pair כמו emoji צורך שניים לכל תו, כך שלעולם לא בודקים מראש לפי ספירת התווים הנראית), וכי גרסת Unicode עשויה לשנות את המחרוזת הזו, העברת string literal או buffer const יכולה לגרום ל-access violation.1

המחרוזת הזו נמסרת ל-process החדש ללא שינוי כחלק מפרמטרי ה-process שלו, ו-child process שולף אותה עם GetCommandLineW. כי מערכת ההפעלה עשויה להשלים את הנתיב המלא לשם ה-exe המוביל, המחרוזת שה-child רואה לא בהכרח תואמת בדיוק את המחרוזת שה-parent העביר.2 ה-lpCmdLine שמועבר ל-WinMain של אפליקציית GUI הוא המחרוזת הזו בלי שם התוכנית.9

הנתיב ש-arguments עוברים עד שהם מגיעים ל-child processמערך ה-arguments של הקורא מחובר למחרוזת אחת ב-lpCommandLine של CreateProcess ומועבר ל-process החדש, ו-child process מפצל את המחרוזת שהוא שולף עם GetCommandLineW באמצעות ה-parser שלו כדי ליצור argvמערך ה-arguments של הקוראמחובר למחרוזת אחת (אחריות הקורא)lpCommandLine של CreateProcessWפרמטרי ה-process של ה-process החדשהמחרוזת ש-GetCommandLineW מחזירה-parser של צד הקבלה מפצלמערך argv / args

איור 2: המערך אף פעם לא חוצה את הגבול. החיבור הוא אחריות הקורא, הפיצול הוא של המקבל, והמערך המקורי משוחזר רק כשכללי שני הצדדים מסכימים.

הנקודה להחזיק כאן היא ש-חיבור ופיצול קורים ב-processes שונים, בקוד שונה. הקורא לא יכול לחבר נכון בלי לדעת במה הצד השני יפצל, ולמקבל אין דרך לדעת איך המחרוזת חוברה. במערכות דמויות Unix, execve מקבל את המערך כמו שהוא, כך שהבעיה הזו לא קיימת. זו הנחה ייחודית ל-Windows, אבל אחת שעוקבת אחרי כל הפעלת process.

3. מי מפצל — שלושה parsers

בצד הקבלה, הקוד שמפצל את המחרוזת ל-argv מגיע בעיקר בשלושה סוגים.

צד קבלה קוד שמפצל מתי הוא נקרא
main / wmain ב-C/C++ קוד ההפעלה של C runtime של MSVC יוצר argc / argv אוטומטית בתחילת התוכנית4
קוד שמשתמש ב-Win32 API ישירות CommandLineToArgvW מעבירים לו את ערך החזרה של GetCommandLineW כדי להמיר לצורה של argv3
Main(string[] args) של .NET / Environment.GetCommandLineArgs() (התצורה הרגילה שמופעלת דרך apphost או dotnet.exe) קוד ההפעלה של C runtime של ה-host (apphost / dotnet.exe) ב-Windows ה-host הוא תוכנית wmain; הוא לוקח את argv שה-C runtime בנה, מסיר את האפשרויות שלו ואת נתיב האפליקציה, ומעביר את השאר ל-runtime יחד עם נתיב האפליקציה. בהפעלה ה-runtime בונה מערך שהאלמנט הראשון שלו הוא שם התוכנית (שם ההפעלה שהועבר מה-host, או נתיב ה-assembly אם אין) ושומר אותו ל-GetCommandLineArgs(), בעוד Main מקבל ב-args רק את ה-arguments בלי שם התוכנית10 11 12
תצורה שטוענת את ה-runtime של .NET כספרייה מתארחת ולא מקבלת arguments בהפעלה קוד הפיצול של ה-runtime של .NET עצמו (SegmentCommandLine) כנפילה, GetCommandLineArgs() מפצל את ערך החזרה של GetCommandLineW עצמו. הוא ממומש להתאים לכללי C runtime ואינו משתמש ב-CommandLineToArgvW, כי זה “מתנהג קצת אחרת”12

קוד הפיצול מגיע בשלושה קווי ייחוס, קוד ההפעלה של C runtime, CommandLineToArgvW, וקוד הפיצול של ה-runtime של .NET עצמו, והם מממשים כללים עם אותו שלד, אבל הם לא אותו קוד. אפליקציית .NET שמופעלת דרך apphost או dotnet.exe מפוצלת בפועל לפי כללי הקו הראשון (קוד ההפעלה של C runtime), כי ה-host עצמו הוא תוכנית wmain שנבנתה עם C runtime של MSVC. מקור ה-runtime של .NET עדיין נושא הערה שאומרת ש-CommandLineToArgvW לא בשימוש כי הוא מתנהג קצת אחרת.12 ההבדלים מופיעים בקצוות, כמו הטיפול ב-"" שמתואר בהמשך, ו-arguments יומיומיים לעתים נדירות פוגעים בהם, אבל ההנחה ש”הכללים זהים, אז הכל הולך” היא מה שנשבר בקצוות.

שלושת ה-parsers בצד הקבלההמחרוזת האחת ש-GetCommandLineW מחזיר מפוצלת על ידי קוד ההפעלה של C runtime ל-C/C++, על ידי CommandLineToArgvW לשימוש Win32 ישיר, ועל ידי קוד הפיצול של ה-runtime עצמו ל-.NET שנטען כספרייה מתארחת; כל אחד עוקב אחרי כללים עם אותו שלד אבל הוא מימוש נפרד. אפליקציית .NET רגילה שמופעלת דרך apphost או dotnet.exe מקבלת את המערך שפוצל על ידי קוד ההפעלה של C runtime של ה-hostהמחרוזת של GetCommandLineWקוד ההפעלה של C runtimeCommandLineToArgvWקוד הפיצול של .NET עצמו (כשנטען על ידי host).NET דרך apphost / dotnet.exe זה אותו דבראותו שלד כללים, מימושים נפרדים

איור 3: יש שלושה קווי ייחוס של קוד פיצול. אפליקציית .NET שמופעלת דרך apphost או dotnet.exe מקבלת את המערך שפוצל על ידי קוד ההפעלה של C runtime של ה-host, וקוד הפיצול של ה-runtime עצמו הוא הנפילה לתצורת הספרייה המתארחת. כי אי אפשר לדעת מבחוץ על איזה מהם ה-exe היעד רץ, התשובה המעשית היא לבנות מחרוזת שנותנת את אותה תוצאה בכולם.

שימו לב ש-args ב-Main(string[] args) של .NET אינו כולל את שם התוכנית, בעוד האלמנט הראשון של Environment.GetCommandLineArgs() כן. האחרון תופס את אותו מקום כמו argv[0] ב-C/C++.13 בהפעלה רגילה כמו dotnet app.dll x, ה-host מסיר את אפשרויות ה-host ואת נתיב האפליקציה (dotnet.exe ו-app.dll), ורק x מגיע ל-args ב-Main.14 GetCommandLineArgs(), לעומת זאת, מחזיר את המערך שאליו ה-runtime הוסיף בהתחלה את שם התוכנית בהפעלה (נתיב app.dll ואחריו x).11 קוד הפיצול של ה-runtime עצמו מפצל את GetCommandLineW רק בתצורת הספרייה המתארחת שלא מקבלת arguments בהפעלה; בתצורה שבה host native מעביר את argc/argv שלו וקורא ל-Main, args ב-Main הוא מה שה-host העביר.

4. כללי הפיצול — רווחים, quotes ו-backslashes

הנה הכללים ששלושת ה-parsers חולקים, ל-argv[1] ואילך.3 4

  1. Arguments מופרדים ב-רווחים או tabs.
  2. אזור עטוף ב-double quotes הופך ל-argument אחד גם אם הוא מכיל רווחים. ה-quotes עצמם אינם חלק מה-argument. quote יכול להתחיל באמצע argument, ואם המחרוזת מסתיימת בלי quote סוגר, הכל עד הסוף הופך ל-argument האחרון.
  3. backslash מטופל כתו רגיל. רק כש-double quote בא מיד אחריו חלים הכללים הבאים.
  4. אם 2n backslashes קודמים ל-double quote, n backslashes יוצאים, וה-quote פועל כ”התחלה או סוף של quoting”.
  5. אם 2n+1 backslashes קודמים ל-double quote, n backslashes ו-quote מילולי יוצאים, ומצב ה-quoting לא משתנה.
  6. ה-caret (^) אינו תו escape (זה כלל של cmd.exe, לא כלל parser).

ה-parser שומר ביט מצב אחד, “האם אני בתוך quotes”, הופך אותו בכל quote, וקורא את המחרוזת משמאל לימין. אם רווח מפריד arguments נקבע לפי המצב הזה.

זרימת הפיצול שעוברת בין בפנים ומחוץ ל-quotesמחוץ ל-quotes ה-parser מפריד arguments ברווחים; כשהוא פוגש quote הוא נכנס פנימה ומתייחס לרווחים כחלק מה-argument; כשהוא פוגש quote נוסף הוא חוזר החוצה. backslash מטופל באופן מיוחד רק כש-quote בא מיד אחריופוגשים quoteפוגשים quotebackslash ואחריו מיד quotebackslash ואחריו מיד quote2n: מוציאים n ופותחים/סוגרים2n+1: מוציאים n ו-quote מילולימחוץ ל-quotes: פיצול ברווחיםבתוך quotes: רווחים הם חלק מה-argumentמחילים את כלל ה-backslashהופכים את מצב ה-quotingשומרים על מצב ה-quoting

איור 4: ליבת הפיצול נקבעת בביט אחד, “בפנים או מחוץ ל-quotes”, ובמספר ה-backslashes מיד לפני quote.

במקום לשנן את הכללים כפרוזה, אמין יותר להסתכל על ההתאמה בין קלט לפלט.

חלק מה-command line (קלט) arguments שיוצאים הכלל שפועל
a b c a, b, c פיצול ברווחים
"a b" c a b, c אזור עטוף לא מפוצל
C:\data\ next C:\data\, next ה-backslash לא אחריו quote, כך שהוא תו רגיל
"C:\data\\" next C:\data\, next השניים לפני ה-quote הופכים לאחד, וה-quote נסגר
"C:\data\" next C:\data" next backslash אחד, כך שה-quote הופך ל-quote מילולי ו-quoting אף פעם לא נסגר, ובולע את ה-argument הבא
"say \"hi\"" say "hi" מספר אי-זוגי, כך quotes מילוליים
"" מחרוזת ריקה הדרך היחידה להעביר argument ריק
'a b' 'a, b' ל-single quotes אין משמעות מיוחדת15

שורה 5 היא הזהות האמיתית של “העברתי C:\data\ והוא מוזג עם ה-argument הבא לאחד” מהפתיחה. ברגע שעוטפים נתיב עם backslash בסוף ב-quotes, ה-quote הסוגר הופך לתו ו-quoting אף פעם לא נסגר.

איך backslash בסוף בולע את ה-argument הבאכשנתיב עם backslash בסוף עטוף ב-quotes, ה-quote שאמור לסגור יושב מיד אחרי backslash יחיד ומפורש כ-quote מילולי, כך ש-quoting אף פעם לא נסגר והכל עד ה-argument הבא נקרא כ-argument אחדמכפילים את ה-backslashנתיב עטוף שמסתיים ב-backslash אחדמספר אי-זוגי של backslashes קודם ל-quote הסוגרה-quote יוצא כתו ו-quoting לא נסגררווחים הבאים כבר לא מפרידיםהכל עד ה-argument הבא מגיע כ-argument אחדquoting נסגר וה-arguments נפרדים

איור 5: למה “להכפיל את ה-backslash בסוף” נחוץ. quoting שנכתב בלי לדעת את הכללים נשבר בסוף נתיב.

“שני quotes רצופים בתוך quoting”, איפה מימושים נבדלים

לכללי C runtime של MSVC יש פריט נוסף אחד: “שני quotes רצופים בתוך מחרוזת עטופה מטופלים כ-quote אחד” (צורה כמו "ab""c", שהיא עניין נפרד מה-"" שמייצג argument ריק).4 לכללים הרשמיים של CommandLineToArgvW, עם זאת, אין פריט כזה, וקוד הבניה של ה-runtime של .NET נמנע במפורש מייצור הצורה הזו כי “quote שאחרי quote סוגר מפורש אחרת על ידי VC לפני ואחרי 2008”.6

כמקבל, מספיק לדעת שקלט כזה יכול להגיע. כבונה, כשרוצים להעביר quote כתו, משתמשים רק בצורה \". היא נותנת את אותה תוצאה בכל parser.

5. argv[0] עוקב אחרי כלל אחר — lpApplicationName ובעיית Program.exe

הטוקן המוביל, כלומר שם ה-exe, מחוץ לכללים עד כאן. מניחים שהוא מחרוזת תקפה כנתיב מערכת קבצים, כך שאפשר לעטוף ב-quotes כדי לכלול רווחים, אבל כללי escaping של backslash לא חלים. גם אין דרך לכלול quote עצמו ב-argv[0].4 3 גם קוד הבניה של .NET מטפל באלמנט הראשון בנפרד: “עוטפים ב-quotes אם יש רווחים, וזורקים אם הוא מכיל quote”.6

מה שהופך לבעיה בצד הקורא הוא ההתנהגות כש-lpApplicationName של CreateProcess הוא NULL. במקרה כזה, המודול להרצה מוסק מ-הטוקן המוביל המופרד ברווחים של lpCommandLine. כשהנתיב מכיל רווחים, עולים כמה מועמדים, ומערכת ההפעלה מנסה אותם מהקצר ביותר.1

הסדר שבו מנחשים את ה-exe כש-lpApplicationName הוא NULLאם C:\Program Files\MyApp -L -S מועבר בלי quotes, CreateProcess בודק קודם C:\Program.exe ואחר כך C:\Program Files\MyApp.exe בסדר הזה, כך שאם C:\Program.exe הונח שם, הוא זה שרץקייםלא קייםמעבירים lpApplicationName, או עוטפים את הטוקן המובילמעבירים נתיב לא עטוף (שמכיל רווחים) ב-lpCommandLineמועמד 1: מנסים C:\\Program.exeexe לא מכוון מתחילמועמד 2: מנסים C:\\Program Files\\MyApp.exeה-exe המכוון מתחיל

איור 6: הנחת נתיב עם רווחים בהתחלה בלי quotes גורמת למערכת ההפעלה לנסות מועמדים מהקצר. התיעוד הרשמי קורא לזה “dangerous” במפורש.

התיעוד הרשמי קובע שאם C:\Program.exe מונח שם, הוא רץ במקום האפליקציה המכוונת, ומבקש לא להעביר NULL ל-lpApplicationName, ואם כן, לעטוף את הנתיב המוביל ב-quotes.1 בפועל, עושים את שניהם. מעבירים את הנתיב המלא של ה-exe ב-lpApplicationName, וגם שמים את אותו נתיב, עטוף ב-quotes, בתחילת lpCommandLine. כששניהם מועברים, המודול שרץ נקבע על ידי lpApplicationName, ו-argv[0] של ה-child process הופך לטוקן המוביל של lpCommandLine. אלא אם שומרים על השניים עקביים לפי מוסכמה, קוד שגוזר את הנתיב שלו מ-argv[0] נשבר. הדרך האמינה לקבל את הנתיב שלכם היא GetModuleFileNameW.4

איך המודול שרץ ו-argv[0] נקבעיםכשגם lpApplicationName וגם lpCommandLine מועברים, המודול שרץ נקבע על ידי lpApplicationName ו-argv[0] של ה-child הוא הטוקן המוביל של lpCommandLine. קוד שגוזר את הנתיב שלו מ-argv[0] נשבר כשהשניים מתפצלים, לכן מקבלים את הנתיב שלכם עם GetModuleFileNameWנשבר כשהם מתפצליםמשתמשים במקוםlpApplicationNameהמודול שרץהטוקן המוביל של lpCommandLineargv[0] של ה-childקוד שגוזר את הנתיב שלו מ-argv[0]GetModuleFileNameW

איור 7: “מה רץ” ו”מה נכנס ל-argv[0]” נקבעים בנפרד. תכנון שגוזר את הנתיב שלו מ-argv[0] לא יכול לעמוד מעל ההפרדה הזו.

נקודה נוספת: כש-lpApplicationName הוא NULL, חלק שם ה-exe של lpCommandLine מוגבל ל-MAX_PATH.1 לטיפול בנתיבים ארוכים, ראו “MAX_PATH ומלכודות נתיב/שם קובץ ב-Windows”.

6. הכללים בצד הבניה — פונקציה אחת מספיקה

ברגע שיודעים את כללי הפיצול, אפשר לבנות “מחרוזת שהצד השני יפצל בחזרה למקור” פשוט בהרצתם הפוכה. לכל argument מ-argv[1] ואילך, עושים את הבא.6

  1. אם הוא לא ריק ואינו מכיל לא רווחים ולא quotes, שמים אותו כמו שהוא.
  2. אחרת, עוטפים את הכל ב-quotes. בתוך ה-quoting,
    • הופכים ריצה של k backslashes מיד לפני quote ל-2k+1 ואז שמים את ה-quote (הפיכת המספר לאי-זוגי הופכת אותו ל-“quote מילולי”);
    • הופכים ריצה של k backslashes בסוף ל-2k (הם קודמים ל-quote הסוגר, כך שמספר זוגי הופך אותו ל-“סוף quoting”);
    • משאירים כל backslash אחר כמו שהוא.
  3. שמים מחרוזת ריקה כ-"".
זרימת ההחלטה לבניית argument אחדאם ה-argument אינו ריק ואינו מכיל לא רווחים ולא quotes, שמים אותו כמו שהוא; אחרת עוטפים ב-quotes, הופכים backslashes לפני quote ל-2k+1 ו-backslashes בסוף ל-2k, שמים backslash לפני quotes, וסוגריםלאכןמקבלים argument אחדריק, או מכיל רווח או quote?שמים כמו שהואquote פותחסורקים משמאלk backslashes לפני quote → 2k+1k backslashes בסוף → 2kכל השאר כמו שהואquote סוגר

איור 8: הבניה היא ההיפוך של כללי הפיצול. יש רק שלושה ענפים, ומתאימים את ספירת ה-backslash רק בסוף ומיד לפני quote; עם זה, כל מחרוזת עושה round-trip, בתנאי שצד הקבלה מפצל wide characters באותם כללי פיצול כמו CommandLineToArgvW, ה-C runtime, ו-.NET (פרק 4) (יעד שמפרש את ה-command line הגולמי בדקדוק משלו, או parser של shell באמצע, מחוץ להיקף), לא הפעיל הרחבת wildcards כמו wsetargv.obj, המחרוזת לא מכילה תווי NUL, והמחרוזת הבנויה כולה נכנסת במגבלת lpCommandLine (32,767 יחידות קוד UTF-16 כולל ה-null הסוגר). (ה-command line הוא מחרוזת שמסתיימת ב-null, כך שתו NUL הוא הדבר האחד שאי אפשר להעביר עקרונית. ביעד עם הרחבת wildcards מופעלת, argument שמכיל * או ? מוחלף בשמות קבצים; ראו פרק 8. מחרוזת מעל המגבלה נדחית על ידי CreateProcessW; ראו פרק 10.)

הכלל הזה משקף את האסימטריה “backslash מיוחד רק מיד לפני quote” כמו שהיא. אין צורך להכפיל מכנית את ה-backslashes שמפרידים רכיבי נתיב; הנקודה היא שנוגעים רק באלה מיד לפני quote ובסוף.

7. מימוש ב-.NET — ArgumentList ו-Arguments

ל-ProcessStartInfo ב-.NET Core 2.1 ואילך יש ArgumentList, שלוקח על עצמו את הבניה הזו. אלמנט אחד הוא argument אחד, המחרוזות שמוסיפים לא צריכות escaping מראש, וב-Process.Start .NET בונה אותן למחרוזת אחת פנימית ומוסר אותה למערכת ההפעלה.5

var psi = new ProcessStartInfo
{
    FileName = @"C:\Program Files\MyTool\convert.exe",
    UseShellExecute = false,
};
psi.ArgumentList.Add("--input");
psi.ArgumentList.Add(inputPath);      // עשוי להכיל רווחים, backslashes בסוף, ו-quotes
psi.ArgumentList.Add("--output");
psi.ArgumentList.Add(outputPath);
psi.ArgumentList.Add("--label");
psi.ArgumentList.Add("");             // argument ריק מועבר נכון כ-""

using var proc = Process.Start(psi)
    ?? throw new InvalidOperationException("Process.Start החזיר null");
proc.WaitForExit();
if (proc.ExitCode != 0)
    throw new InvalidOperationException($"convert.exe נכשל (ExitCode={proc.ExitCode})");

Arguments הוא מאפיין שמעביר מחרוזת אחת שבניתם בעצמכם כמו שהיא. השניים עצמאיים, וכשמשתמשים באחד, השני חייב להיות ריק.16 גם התיעוד הרשמי מייעץ לבחור ArgumentList אם לא בטוחים ב-quoting.5

איפה ArgumentList ו-Arguments הופכים למחרוזתעם ArgumentList, .NET עושה escape לכל אלמנט ובונה מחרוזת אחת לפני שמעביר ל-CreateProcess; עם Arguments, המחרוזת שהקורא בנה מועברת כמו שהיא. בכל מקרה, מה שמגיע למערכת ההפעלה הוא מחרוזת אחתArgumentList (1 אלמנט = 1 argument).NET עושה escape לכל אלמנט ומחברArguments (מחרוזת אחת שבניתם בעצמכם)כמו שהיאמחרוזת command-line אחתCreateProcess

איור 9: במה שמשתמשים, מה שמגיע למערכת ההפעלה הוא מחרוזת אחת. ההבדל היחיד הוא מי בונה אותה, ו-ArgumentList משאיר את זה לצד שמכיר את הכללים.

קוד הבניה מאחורי ArgumentList הוא בדיוק כללי פרק 6. אם ה-argument אינו ריק ואינו מכיל לא רווחים ולא quotes, הוא מונח כמו שהוא; אחרת הוא עטוף ב-quotes, backslashes מיד לפני quote הופכים ל-2k+1, backslashes בסוף הופכים ל-2k, וכל quote תמיד מקבל backslash לפניו. הוא אף פעם לא מייצר את הצורה עם quotes סמוכים בתוך argument שאינו ריק. רק argument ריק מונח כ-"", וזו הצורה הנכונה.6

ב-.NET Framework, בונים בעצמכם

ArgumentList הוא API שהוצג ב-.NET Core 2.1 ואינו קיים ב-ProcessStartInfo ב-.NET Framework.5 באפליקציית .NET Framework 4.8, או בכלי פנימי שנבנה על אחת, כותבים את כללי פרק 6 בעצמכם ומעבירים את התוצאה ל-Arguments.

// ל-.NET Framework. בונה את המחרוזת האחת להעברה ל-ProcessStartInfo.Arguments.
// הכללים הם אותם כללים ש-ProcessStartInfo.ArgumentList משתמש בהם פנימית.
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("argument לא יכול להכיל תו NUL (ה-command line הוא מחרוזת שמסתיימת ב-null, ולכן ייחתך שם)");

    bool needsQuote = arg.Length == 0 || arg.Any(c => char.IsWhiteSpace(c) || c == '"');
    if (!needsQuote)
    {
        sb.Append(arg);                       // כמו שהוא
        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); // בסוף: מוכפל כי ה-quote הסוגר בא אחרי
        }
        else if (arg[i] == '"')
        {
            sb.Append('\\', backslashes * 2 + 1).Append('"'); // לפני quote: מוכפל ועוד אחד
            i++;
        }
        else
        {
            sb.Append('\\', backslashes).Append(arg[i]);      // כל השאר: כמו שהוא
            i++;
        }
    }
    sb.Append('"');
}

הנה קלטים ופלטים זה לצד זה.

ערך להעביר המחרוזת ש-AppendArgument מוציא
strict strict
מחרוזת ריקה ""
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\ (בלי רווחים) C:\data\

שימו לב לשורה האחרונה. ערך שאינו מכיל לא רווחים ולא quotes לא עטוף, כך שה-backslash בסוף יוצא כמו שהוא. בלי עטיפה, כללים 4 ו-5 אף פעם לא נורים, ו-C:\data\ מגיע נכון.

בחירת שיטת הבניה לפי גרסת .NETב-.NET Core 2.1 ואילך, משאירים ל-ProcessStartInfo.ArgumentList; ב-.NET Framework, בונים את מחרוזת Arguments בפונקציה עצמאית באותם כללים. באף אחד מהם לא כותבים quotes ביד עם חיבור מחרוזותCore 2.1 ואילךFrameworkאיזו גרסת .NET?מוסיפים ל-ArgumentList אלמנט אחד בכל פעםבונים Arguments בפונקציה עצמאיתאף פעם לא כותבים quotes ביד

איור 10: שתי שיטות, עיקרון אחד. נשארים על “אף פעם לא כותבים quotes ביד” והשבירה בסוף נתיב אף פעם לא קורה.

שימו לב שעם UseShellExecute = true, ההפעלה עוברת דרך ShellExecuteEx ולא דרך CreateProcess, ותוכן ArgumentList הופך לפרמטרים שמועברים ל-shell. כשפותחים מסמך או URL, שיוך הקבצים בונה את ה-command line של ה-handler בפועל, כך שהמחרוזת שבניתם כאן לא בהכרח מגיעה ליעד כמו שהיא. לשימושים שבהם מפנים פלט או צריכים את קוד היציאה באופן אמין, מגדירים UseShellExecute = false ומתכננים את הקוד לקרוא פלט סטנדרטי ושגיאה סטנדרטית באותו זמן. החלק הזה מכוסה ב-“רשימת בדיקה לניהול בטוח של child processes באפליקציית Windows”.

8. מימוש ב-C++ / Win32

ב-C++ כותבים את שני הצדדים בעצמכם, בניה ופיצול. לבניה, הופכים את כללי פרק 6 ישירות לפונקציה.

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

// מוסיף argument אחד ל-argv[1] ואילך. הכללים הם ההיפוך של כללי הפיצול של 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("argument לא יכול להכיל תו NUL (ה-command line הוא מחרוזת שמסתיימת ב-null, ולכן ייחתך שם)");

    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'\\');           // בסוף: מוכפל
            break;
        }
        if (arg[i] == L'"') {
            cmd.append(backslashes * 2 + 1, L'\\');       // לפני quote: מוכפל ועוד אחד
            cmd += L'"';
        } else {
            cmd.append(backslashes, L'\\');               // כל השאר: כמו שהוא
            cmd += arg[i];
        }
        ++i;
    }
    cmd += L'"';
}

// argv[0] (ה-exe) עוקב אחרי כלל אחר: עוטפים ב-quotes רק אם יש רווחים. הוא לא יכול להכיל quote.
std::wstring QuoteArgv0(std::wstring_view exe)
{
    if (exe.find(L'\0') != std::wstring_view::npos)
        throw std::invalid_argument("נתיב ה-exe לא יכול להכיל תו NUL (גם lpApplicationName וגם ה-command line ייחתכו שם, והנתיב עד לאותה נקודה עלול להיות מופעל)");
    if (exe.find(L'"') != std::wstring_view::npos)
        throw std::invalid_argument("נתיב ה-exe לא יכול להכיל quote");
    if (exe.empty() || exe.find_first_of(L" \t") != std::wstring_view::npos)
        return L'"' + std::wstring(exe) + L'"';
    return std::wstring(exe);
}

בקריאה, מעבירים את הנתיב המלא של ה-exe ב-lpApplicationName ו-buffer בר-כתיבה ב-lpCommandLine.

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

std::wstring cmd = QuoteArgv0(exe);          // שומרים argv[0] עקבי עם ה-exe
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 עשוי לשנות את המחרוזת

STARTUPINFOW si{}; si.cb = sizeof(si);
PROCESS_INFORMATION pi{};
if (!CreateProcessW(exe.c_str(),             // lpApplicationName: אף פעם לא NULL
                    buffer.data(),           // lpCommandLine: מתחיל באותו נתיב, עטוף
                    nullptr, nullptr, FALSE, CREATE_UNICODE_ENVIRONMENT,
                    nullptr, nullptr, &si, &pi)) {
    const DWORD err = GetLastError();
    // רושמים את err כאן ומחזירים אותו לקורא. לא בולעים אותו
    return;
}
CloseHandle(pi.hThread);                     // handle ה-thread הראשי לא נחוץ, אז סוגרים קודם

switch (WaitForSingleObject(pi.hProcess, INFINITE)) {   // מוסיפים timeout אם צריך
case WAIT_OBJECT_0: {                        // הוא יצא. קוראים את קוד היציאה רק בענף הזה
    DWORD exitCode = 0;
    if (!GetExitCodeProcess(pi.hProcess, &exitCode)) {
        const DWORD err = GetLastError();
        // רושמים גם את כשל השליפה, ומחזירים אותו לקורא ככישלון
    } else if (exitCode != 0) {
        // היעד עלה אבל העיבוד שלו נכשל. לא מתייחסים אליו כמו 0;
        // רושמים את קוד היציאה ומחזירים אותו לקורא (אותו דבר כמו בדיקת ExitCode בדוגמת C#)
    }
    break;
}
case WAIT_TIMEOUT:
    // עדיין רץ. קריאה ל-GetExitCodeProcess כאן מחזירה רק STILL_ACTIVE (259),
    // שזה לא קוד יציאה. הדוגמה הזו לוקחת את המדיניות "לקפל timeout לכישלון":
    // רק כשבקשת הסיום עוברת אנחנו רואים אותו יוצא, ואז ממשיכים ל-CloseHandle למטה.
    // אם המדיניות היא להמשיך להמתין, לא עושים break כאן ולא סוגרים את ה-handles (זה היה
    // משחרר את ה-child בזמן שהוא עדיין רץ). חוזרים להמתנה
    if (!TerminateProcess(pi.hProcess, 1)) {
        const DWORD err = GetLastError();
        // לא הצלחנו לסיים אותו (הרשאות לא מספיקות וכו'). המתנה עם INFINITE כאן הייתה הופכת
        // את הדדליין שנוסף כדי למנוע חריגות לחסר משמעות. רושמים את err ומחזירים כישלון לקורא
        // בלי להמתין (ה-child משוחרר בזמן שהוא עדיין רץ, אז רושמים גם את זה)
        break;
    }
    WaitForSingleObject(pi.hProcess, INFINITE); // בקשת הסיום עברה, אז רואים אותו יוצא לפני הסגירה
    // מחזירים את ה-timeout לקורא ככישלון
    break;
default: {                                   // WAIT_FAILED
    const DWORD err = GetLastError();
    // רושמים גם את כשל ההמתנה עצמה
    break;
}
}
CloseHandle(pi.hProcess);                    // שכחה של זה מדליפה handle אחד לכל הפעלה
חלוקת התפקידים בין שני ה-arguments שמועברים ל-CreateProcessWlpApplicationName מקבע את המודול להרצה, ו-lpCommandLine קובע את המחרוזת ש-child process מקבל דרך GetCommandLineW. מעבירים lpCommandLine כ-buffer בר-כתיבה ושומרים את argv[0] המוביל עקבי עם lpApplicationNameשומרים עקביlpApplicationName: הנתיב המלא של ה-exeהמודול להרצה מקובעlpCommandLine: buffer בר-כתיבההמחרוזת שה-child מקבל דרך GetCommandLineWטוקן מוביל = argv[0]השאר = arguments שנבנו לפי כללי פרק 6

איור 11: “מה להריץ” ו”מה להעביר” נקבעים ב-arguments שונים. הופכים את שניהם למפורשים ואף אחת מבעיית Program.exe ומה-access violation מ-buffer לא בר-כתיבה לא קורה.

בצד הקבלה, מעבירים את ערך החזרה של GetCommandLineW ל-CommandLineToArgvW כדי לקבל אותו בצורה של argv. משחררים את ערך החזרה ב-LocalFree אחד. יש התנהגויות קצה: אם lpCmdLine הוא מחרוזת ריקה, מוחזר נתיב ה-exe הנוכחי, ואם הוא מתחיל ברווח, ה-argument הראשון הופך למחרוזת ריקה.3

int argc = 0;
LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc);
if (argv == nullptr) {
    const DWORD err = GetLastError();
    // רושמים גם את כשל ה-parse
    return 1;
}
for (int i = 0; i < argc; ++i) {
    // argv[0] הוא שם ה-exe. מערכת ההפעלה עשויה להשלים את הנתיב המלא
}
LocalFree(argv);

אם משתמשים ב-main / wmain, ה-C runtime עושה את אותו דבר בשבילכם בהפעלה. שימו לב, עם זאת, ש-argv ב-main הוא מחרוזת צרה שהומרה לדף הקוד הנוכחי, כך שתווים שדף הקוד לא יכול לייצג (למשל נתיב יפני במחשב מחוץ לסביבה יפנית) אובדים כאן. פונקציית הבניה של פרק 6 עושה “round-trip” מול מקבלים שמפצלים wide characters כמו שהם, כמו wmain, CommandLineToArgvW, ו-.NET. כברירת מחדל wildcards לא מורחבים, אבל קישור setargv.obj (wsetargv.obj ל-wmain) גורם לו להרחיב * ו-?.4 אם מעבירים argument שמכיל * בשם קובץ ליעד עם ההגדרה הזו, ה-arguments שמגיעים שונים ממה שכיוונתם.

9. כש-cmd.exe וקבצי batch נכנסים באמצע

הכללים עד כאן חלים כשהמחרוזת הולכת ישירות מ-CreateProcess ל-exe היעד. כש-cmd.exe יושב באמצע, מתווסף עוד שלב פרשנות אחד.

cmd.exe מתייחס ל-&, |, ( ו-) כתחביר, וכדי להעביר אותם כ-arguments צריך לעשות להם escape עם ^ או לעטוף ב-quotes. הטיפול ב-quotes במחרוזת שאחרי /c או /k יש לו כללים משלו, ואם “ה-quotes החיצוניים מקולפים” משתנה עם נוכחות /s, מספר ה-quotes, ונוכחות תווים מיוחדים.17 יתר על כן, קובץ batch מקבל את ה-arguments לא מפוצלים אלא כ-מחרוזת command-line גולמית. התיעוד הרשמי של PowerShell מזהיר בבירור מפני העברת קלט לא מהימן לקובצי batch.7 התיעוד של CreateProcess אומר שכדי להפעיל קובץ batch מציינים cmd.exe ב-lpApplicationName ומעבירים /c ועוד שם ה-batch, ואז מציין שצוות ההנדסה של MSRC לא ממליץ על השיטה הזו, עם קישור לכתיבה על MS14-019.1 מה ש-MS14-019 תיקן היה הבעיה שכשקובץ batch הועבר ישירות ל-CreateProcess, cmd.exe חופש קודם בתיקייה הנוכחית ויכול היה להיות נחטף, וההמלצה של MSRC היא “להעביר את הנתיב המלא של cmd.exe ולהפוך את קובץ ה-batch ל-argument שלו”.18 כלומר, הבעיה היא הפעלת קובץ batch בלי לנקוב בשם cmd.exe בנתיב המלא (הגדרת lpApplicationName ל-NULL ותן לשם ה-batch להתחיל אותו), לא ההפעלה עם /c עצמה עם הנתיב המלא של cmd.exe ב-lpApplicationName.

cmd.exe באמצע מוסיף שלבי פרשנותהפעלת ה-exe היעד ישירות פירושה שהפיצול קורה פעם אחת, ב-parser של היעד, אבל מעבר דרך cmd.exe /c מוסיף את פרשנות התחביר של cmd.exe, וקובץ batch מעל זה מקבל את המחרוזת הגולמית, כך שכללי ה-quoting משתנים בכל שלבה-process שלכם → ה-exe היעדפיצול פעם אחת, רק על ידי ה-parser של היעדה-process שלכם → cmd.exe /c → ה-exe היעדפרשנות התחביר של cmd.exe מתווספת (ampersand, pipe, סוגריים, caret)פיצול על ידי ה-parser של היעדה-process שלכם → cmd.exe /c → קובץ batchקובץ ה-batch מקבל את המחרוזת הגולמיתהעברת ערכים לא מהימנים דרכו הופכת ל-command injection

איור 12: ככל שיש יותר שלבים, כך הכללים מתערבבים. מפעילים ישירות מה שאפשר להפעיל ישירות, ואף פעם לא מעבירים ערכים שהגיעו מבחוץ לקובץ batch.

ההחלטה המעשית פשוטה. אם היעד הוא exe, לא שמים cmd.exe באמצע. אם אין ברירה אלא לקרוא ל-.bat, העיקרון הוא לא לתת ל-batch לפרש ערכים שהגיעו מבחוץ. כותבים את הערכים לקובץ, נותנים ל-batch להעביר רק את נתיב הקובץ הזה, כמחרוזת קבועה, ל-exe במורד הזרם, וקוראים את תוכן הקובץ בצד ה-exe. הכנסת הערך למשתנה סביבה אינה גבול, כי ברגע שה-batch מרחיב אותו כ-%VAR%, & ו-| מפורשים מחדש על ידי cmd.exe. מעבר דרך משתנה סביבה מקובל רק כשה-exe במורד הזרם קורא את המשתנה ישירות בלי לעבור ב-batch. אם גם זה קשה, מעבירים את תוכן ה-batch ל-PowerShell או ל-exe שלכם (“האם קובץ ה-batch הזה צריך לעבור ל-PowerShell?”).

10. מגבלות אורך

המגבלות גם נבדלות לפי נתיב.

נתיב מגבלה מקור
lpCommandLine של CreateProcess 32,767 יחידות קוד UTF-16 (כולל ה-null הסוגר; surrogate pair נספר כשניים) 1
חלק שם ה-exe כש-lpApplicationName הוא NULL MAX_PATH 1
ה-command line של cmd.exe (כולל שורות בקובץ batch) 8,191 תווים 8
ProcessStartInfo.Arguments של .NET אורך מחרוזת (יחידות קוד UTF-16) מתחת ל-32,699 16

תכנון שמניח ערכים באורך משתנה כמו רשימת קבצים כ-arguments פוגע במגבלה ביום שהמספר גדל. לשימושים שמתקרבים למגבלה, עוברים לשיטת “response file”: כותבים את ה-arguments לקובץ אחד ו-מעבירים רק את נתיב הקובץ הזה. ה-workaround הרשמי למגבלת cmd.exe הוא אותה שיטה.8 לא CreateProcess ולא cmd.exe, עם זאת, מרחיבים את הקובץ בשבילכם. השיטה הזו עובדת רק אם תוכנית היעד יכולה לקרוא response file בתחביר כמו @file, או אם אפשר לתקן את היעד כך שיוכל. אם היעד הוא exe מדף שאין אפשרות לשנות, האפשרות היחידה היא לפצל את הקריאות כך שכל אחת נכנסת במגבלה.

מגבלות העברת ערכים באורך משתנה כ-arguments, והדרך סביבןהנחת ערכים באורך משתנה כמו רשימת קבצים כ-arguments מגיעה למגבלת cmd.exe של 8191 תווים או למגבלת CreateProcess של 32767 יחידות קוד UTF-16 כשהמספר גדל. אם היעד יכול לקרוא response file (או אפשר לתקן אותו), עוברים לשיטת response-file של כתיבת הערכים לקובץ והעברת הנתיב בלבד; אם היעד הוא exe מדף שאי אפשר, מפצלים את הקריאותהיעד יכול לקרוא response fileexe מדף שאי אפשרמניחים ערכים באורך משתנה (רשימת קבצים וכו') כ-argumentsהמחרוזת גדלה כשהמספר גדלמגיעים למגבלה (cmd.exe 8,191 / CreateProcess 32,767)יום אחד ההפעלה נכשלת פתאוםכותבים את הערכים לקובץ ומעבירים רק את הנתיב (response file)מפצלים את הקריאות

איור 13: המגבלה היא סוג הבעיה ש”בסדר היום”. ל-arguments שגדלים ביחס למספר, אם היעד יכול לקרוא response file (או אפשר לתקן אותו), עושים את זה ככה מההתחלה.

11. בודקים מה באמת הגיע

לפני שמוסיפים quoting בניחוש, הנתיב הקצר ביותר הוא להסתכל על ה-arguments שהגיעו ליעד. יש שלושה דברים להסתכל עליהם, “המחרוזת שנבנתה בצד הקורא”, “המחרוזת שהגיעה לצד היעד”, ו”המערך אחרי פיצול”, וארבעה אמצעים לעשות זאת. לפני זה, הבטחה אחת. באיזה אמצעי שמשתמשים, מצנזרים secrets לפני שרושמים command line בלוג. אם התכנון שם סיסמאות, מפתחות API או tokens ב-arguments, כתיבתם כמו שהם משאירה את ה-secrets בלוג, בין אם זה לוג הקורא או לוג ה-startup של היעד. לוגים נשמרים יותר מה-process ונראים על ידי יותר אנשים. מלכתחילה, command line ניתן לקריאה על ידי processes אחרים באותו מחשב, כמו עם Process Explorer שמתואר בהמשך, כך שהנגד היסודי הוא תכנון שמעביר סיסמאות ו-tokens לא כ-arguments אלא בנתיב אחר כמו קלט סטנדרטי או מאגר תצורה מוגן; צנזורה בלוגים היא הגנה מעל זה. או מפרשים את ה-arguments המפוצלים (בצד הקורא, האלמנטים לפני הבניה) ומצנזרים את ערכי האפשרויות שיכולות להיות secret לפני הרישום, או מאפשרים רישום של המחרוזת הגולמית רק במצב אבחון מוגבל.

  1. בצד הקורא, רושמים את המחרוזת שבניתם. זה ה-lpCommandLine מיד לפני שהוא מועבר ל-CreateProcess. ההשוואה הזו מניחה הפעלה עם UseShellExecute = false או קריאה ישירה ל-CreateProcess. כשפותחים מסמך או URL עם UseShellExecute = true, שיוך הקבצים בונה את ה-command line בפועל דרך ShellExecuteEx (פרק 7), כך שמחרוזת הקורא ומחרוזת היעד נבדלות גם בלי cmd.exe או קובץ batch, וזו לא בעיית פרק 9. אם משתמשים ב-ArgumentList של .NET, רישום רשימת האלמנטים כמו שהיא לא יכול לשמש להשוואה. האלמנטים הם הערכים לפני quoting ולפני שה-backslashes בסוף מוכפלים, ומה שמגיע למערכת ההפעלה הוא המחרוזת ש-.NET עיצב מהם. או משחזרים מחרוזת אחת מהאלמנטים באותם כללים כמו BuildArguments בפרק 7 ורושמים אותה (זה נותן את אותה תוצאה כמו העיצוב ש-ArgumentList עושה פנימית), או משווים את רשימת האלמנטים ישירות מול המערך אחרי פיצול. זה האמצעי היחיד לראות “ה-buffer המקורי של הקורא”; Process Explorer ולוג היעד, שמתוארים למטה, מראים רק את המחרוזת ששלב cmd.exe או batch באמצע בנה מחדש. ברישום, שומרים על ההבטחה מהפתיחה ומצנזרים את ערכי האלמנטים שיכולים להיות secret (אלמנט מצונזר כבר לא תואם את מחרוזת היעד, כך שמוציאים את האלמנט הזה מההשוואה).
  2. מכינים exe שמציג רק את ה-arguments שלו. מפעילים אותו במקום ה-exe היעד ונותנים לו להדפיס את ה-args שקיבל, אחד בכל שורה. אם כותבים את הערכים כמו שהם, argument שמכיל שורות חדשות או תווי בקרה יכול להופיע ככמה שורות או לדרוס שורות שכנות וסופרים לא נכון, כך שמדפיסים כל ערך ב-escape כמחרוזת JSON יחד עם האורך שלו (ה-escaping הפיך, כך שאפשר לשחזר את הערך המקורי). זכרו, עם זאת, שכפי שפרק 3 מסביר יש שלושה קווי ייחוס של parser, והם מפרשים צורות קצה כמו שני quotes רצופים בתוך quoting אחרת. משתמשים ב-exe תצוגה שנבנה עם אותו runtime כמו היעד (C++ עם wmain אם היעד הוא MSVC C/C++, .NET אם הוא .NET). אם היעד הוא תוכנית שלכם, הגישה האמינה ביותר היא לדלג על exe התצוגה ולרשום argv בהפעלה של היעד עצמו (תחת כלל הצנזורה בפריט הבא). ל-.NET, כמה השורות הבאות מספיקות.
using System.Text.Encodings.Web;
using System.Text.Json;

// עושים escape לשורות חדשות, תווי בקרה, quotes ו-backslashes; מוציאים טקסט יפני כמו שהוא
var json = new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };

Console.WriteLine("CommandLine: " + JsonSerializer.Serialize(Environment.CommandLine, json)); // המחרוזת האחת
for (int i = 0; i < args.Length; i++)
    Console.WriteLine($"[{i}] len={args[i].Length} {JsonSerializer.Serialize(args[i], json)}");
    // אחרי פיצול. כל רשומה תמיד נכנסת בשורה אחת, ומחרוזת ריקה מוצגת כ-len=0 ו-"". len הוא ביחידות קוד UTF-16
  1. מסתכלים על ה-command line של ה-child process ב-Process Explorer. מאפייני ה-process מראים את מחרוזת ה-command-line שה-child process מחזיק. זה אמצעי לבדוק “המחרוזת שהגיעה לצד היעד”; הוא לא אומר “המערך אחרי פיצול”. מה שמוצג הוא המחרוזת שמוחזקת בצד ה-child process, כך שכפי שנגענו בפרק 2 מערכת ההפעלה עשויה להשלים את הנתיב המלא לשם ה-exe המוביל, ואם cmd.exe או קובץ batch באמצע, מה שרואים הוא המחרוזת ש-cmd.exe בנה מחדש. הנקודות המפתח הן לא להיבהל מהבדל בטוקן המוביל לבדו, ושמחרוזת המקור של הקורא ניתנת לידיעה רק מהלוג בפריט 1. השימוש מכוסה ב-“Process Explorer / Handle / VMMap בפועל”.
  2. בהפעלה של האפליקציה שלכם, רושמים את ה-command line שהיא קיבלה. כשמישהו בשטח אומר “זה לא עולה”, כשיש רשומה של המחרוזת שבה זה הופעל אפשר לבודד קודם אם זו בעיית argument. גם כאן, לא שומרים את ערך החזרה של GetCommandLineW כמו שהוא. שומרים על ההבטחה מהפתיחה: או מפרשים את ה-arguments המפוצלים ומצנזרים ערכים שיכולים להיות secret לפני הרישום, או מאפשרים רישום של המחרוזת הגולמית רק במצב אבחון מוגבל.

סדר ההשוואה הוא כדלקמן. קודם משווים את מחרוזת הקורא (פריט 1) עם מחרוזת היעד (פריט 3 או 4). אם הן לא תואמות מלבד שם ה-exe המוביל, שלב באמצע שינה אותה: cmd.exe או קובץ batch להפעלה ישירה (פרק 9), או שיוך הקבצים של ה-shell ל-UseShellExecute = true (פרק 7). החלפת הקוד שלכם בפונקציה של פרק 6 לא תתקן את זה. אם הן תואמות, משווים את המחרוזת הזו עם המערך אחרי פיצול (פריט 2). אם הוא מפוצל לפי הכללים אבל אינו המערך שרוצים, הבעיה בצד הבניה; אם הוא לא מפוצל לפי הכללים, הבעיה היא ה-parser של המקבל.

הסדר לבידוד בעיית argumentקודם משווים את לוג המחרוזת שנבנתה בצד הקורא עם מחרוזת היעד כפי שנראית ב-Process Explorer או בלוג ה-startup של היעד. אם הן לא תואמות מלבד שם ה-exe המוביל, שלב באמצע (cmd.exe או קובץ batch להפעלה ישירה, שיוך הקבצים של ה-shell ל-UseShellExecute=true) שינה אותה. אם הן תואמות, משווים עם המערך אחרי פיצול; אם הוא מפוצל לפי הכללים אבל אינו המערך שרוצים, הבעיה בצד הבניה, ואם הוא לא מפוצל לפי הכללים, הבעיה היא ה-parser של המקבללאכןכן: מפוצל, אבל לא המערך שרוציםלא: לא מפוצל לפי הכלליםה-arguments שגוייםמסתכלים על המחרוזת שנבנתה בצד הקורא (לוג הקורא)מסתכלים על מחרוזת היעד (Process Explorer / לוג ה-startup של היעד)האם הן תואמות מלבד שם ה-exe המוביל?שלב באמצע שינה אותה (ראו פרקים 9 ו-7)מסתכלים על המערך אחרי פיצול (exe תצוגה על אותו runtime כמו היעד)האם המחרוזת והמערך תואמים לפי הכללים?בעיה בצד הבניה: מחליפים בפונקציה של פרק 6בעיה ב-parser של המקבל

איור 14: משווים את שלושת הדברים בסדר, “מחרוזת הקורא”, “מחרוזת היעד”, ו”המערך”, וזה נקבע מכנית אם האחריות היא על שלב באמצע, צד הבניה, או צד הקבלה. הוספת escapes בניחוש יכולה לחכות עד אחרי הבדיקה הזו.

12. מדריך גס (טבלת החלטה)

מצב מה לעשות
מפעילים exe מ-.NET Core 2.1 ואילך / .NET 5 ואילך מוסיפים ל-ProcessStartInfo.ArgumentList אלמנט אחד בכל פעם
מפעילים exe מ-.NET Framework בונים Arguments בפונקציה לפי כללי פרק 6. אף פעם לא כותבים quotes ביד
מפעילים מ-C++ מעבירים lpApplicationName, ובונים lpCommandLine לפי הכללים ב-buffer בר-כתיבה
רוצים quote בתוך ערך argument משתמשים רק בצורה \". אף פעם לא שמים quotes סמוכים בתוך argument שאינו ריק
הנתיב מסתיים ב-backslash אם עוטפים, מכפילים את ה-backslash בסוף. אם אין רווחים, לא עוטפים
רוצים להעביר argument ריק שמים "". אם משמיטים, כל ה-argument נעלם
נתיב ה-exe מכיל רווח מעבירים lpApplicationName, ועוטפים גם את הטוקן המוביל ב-quotes
אין ברירה אלא לקרוא ל-.bat לא נותנים ל-batch לפרש ערכים שהגיעו מבחוץ. כותבים אותם לקובץ ונותנים ל-exe במורד הזרם לקרוא אותו (משתנה סביבה שמורחב כ-%VAR% בתוך ה-batch אינו גבול)
ה-arguments מתארכים אם היעד יכול לקרוא response file (או אפשר לתקן אותו), עוברים ל-response file. ל-exe מדף, מפצלים את הקריאות
לא יודעים מה מגיע משווים את השלושה בסדר: לוג הקורא, מחרוזת היעד (Process Explorer / לוג ה-startup), והמערך אחרי פיצול (exe תצוגה על אותו runtime כמו היעד)

13. סיכום

command-line arguments ב-Windows חוצים את הגבול לא כמערך אלא כ-מחרוזת אחת. הקורא מחבר, המקבל מפצל, וכללי הפיצול מתמצים לשלושה: “פיצול ברווחים”, “עטיפה ב-quotes”, ו”רק backslashes מיד לפני quote מיוחדים”. רק שם ה-exe המוביל עוקב אחרי כלל אחר, והשמטת lpApplicationName הופכת את הפרשנות של נתיב שמכיל רווחים לדו-משמעית.

מה שצד הבניה צריך לעשות נכנס בפונקציה אחת, וב-.NET Core 2.1 ואילך ArgumentList מטפל בזה. ל-exe, מעבירים את הנתיב המלא ב-lpApplicationName וגם שמים את אותו נתיב, עטוף ב-quotes, בתחילת lpCommandLine (ב-.NET, משאירים ל-FileName). אף פעם לא מייצרים את הצורה עם quotes סמוכים בתוך argument שאינו ריק (ה-"" שמייצג argument ריק שונה), אף פעם לא מעבירים ערכים שהגיעו מבחוץ דרך cmd.exe או קובץ batch, ול-arguments שגדלים ביחס למספר, משתמשים ב-response file רק כשהיעד יכול לקרוא אחד (או אפשר לתקן אותו שיקרא), ואחרת מפצלים את הקריאות. שומרים על חמש הנקודות האלה והכשלים “זה לא עולה רק במחשב עם רווח בנתיב” ו”ה-argument הבא נעלם בגלל backslash בסוף” אף פעם לא קורים.

חמש הבטחות שמונעות כשלי argumentsמעבירים את הנתיב המלא של ה-exe ב-lpApplicationName ועוטפים גם את הטוקן המוביל ב-quotes, משאירים quoting לפונקציה שעוקבת אחרי הכללים או ל-ArgumentList, אף פעם לא מייצרים את הצורה עם quotes סמוכים בתוך argument שאינו ריק, אף פעם לא מעבירים ערכים מבחוץ דרך cmd.exe או קובץ batch, ומשתמשים ב-response file ל-arguments שגדלים עם המספר רק כשהיעד יכול לקרוא אחד. בהנחה שהיעד מפרש לפי כללי הפיצול שפורסמו ולא הפעיל הרחבת wildcards, חמש הנקודות האלה מונעות את הכשלים שנגרמים מנתיבים עם רווחים ומ-backslashes בסוףמעבירים את הנתיב המלא ב-lpApplicationName ועוטפים גם את הטוקן המובילמשאירים quoting לפונקציה לפי הכללים או ל-ArgumentListאף פעם לא מייצרים quotes סמוכים בתוך quotingאף פעם לא מעבירים ערכים מבחוץ דרך cmd.exe או קובץ batchמשתמשים ב-response file ל-arguments שגדלים (כשהיעד יכול לקרוא)אין כשלים מרווחים או מ-backslashes בסוף

איור 15: כל אחת מחמש ההבטחות היא ניסוח מחדש של “מקבעים את המודול להרצה, ומעבירים רק מחרוזות שה-parser של היעד יכול לפצל”. ההנחה היא שהיעד מפרש לפי כללי הפיצול שפורסמו ולא הפעיל הרחבת wildcards (פרקים 6 ו-8); מעל זה, חמש הנקודות האלה מונעות את הכשלים שנגרמים מרווחים ומ-backslashes בסוף.

כשדברים לא עובדים, לפני שמוסיפים escapes בניחוש, מסתכלים על שלושת הדברים: המחרוזת שנבנתה בצד הקורא, המחרוזת שהגיעה לצד היעד, והמערך אחרי פיצול. אם מחרוזות הקורא והיעד נבדלות, שלב באמצע אחראי (cmd.exe או קובץ batch, או שיוך הקבצים של ה-shell ל-UseShellExecute = true); אם הן זהות, ההתאמה בין המחרוזת למערך מחליטה אם זה צד הבניה או צד הקבלה.

מאמרים קשורים

תחומי ייעוץ קשורים

KomuraSoft LLC מטפלת בתכנון אפליקציות Windows שמשלבות כלים חיצוניים ו-EXEs פנימיים, בחקירת שורש של הפעלות child-process ש”עולות בחלק מהסביבות ולא באחרות”, ובסקירות של קוד הפעלת process כחלק ממעבר מ-.NET Framework ל-.NET. אפשר ליצור קשר גם על מקרה בודד של “ה-arguments מתקלקלים”.

קישורים

  1. Microsoft Learn, CreateProcessW function (processthreadsapi.h). על כך ש-lpCommandLine הוא מחרוזת אחת של לכל היותר 32,767 תווים (כולל ה-null הסוגר; יחידות קוד UTF-16, כי זו מחרוזת רחבה), גרסת Unicode עשויה לשנות את תוכנה כך שאי אפשר להעביר זיכרון לקריאה בלבד, הטוקן המוביל המופרד ברווחים הופך לשם המודול כש-lpApplicationName הוא NULL עם נתיב שמכיל רווחים שמפורש החל מ-c:\program.exe, הסכנה ש-exe אחר ירוץ אם Program.exe מונח שם והצורך להימנע מ-NULL או לעטוף את הנתיב ב-quotes, argv[0] עשוי לא להתאים לשם המודול כששניהם צוינו, חלק שם המודול מוגבל ל-MAX_PATH כש-NULL, ו-cmd.exe /c נחוץ להפעלת קובץ batch. ראו גם את ההערה ב-CreateProcessA function שצוות ההנדסה של MSRC לא ממליץ על השיטה הזו (עם קישור לכתיבה על MS14-019).  2 3 4 5 6 7 8 9 10

  2. Microsoft Learn, GetCommandLineW function (processenv.h). על כך שהוא מחזיר את מחרוזת ה-command-line של ה-process הנוכחי, ערך החזרה לא לשחרר ולא לשנות, ניתן להמרה לצורה של argv דרך CommandLineToArgvW, ועשוי לא להתאים למחרוזת שה-parent העביר ל-CreateProcess כי מערכת ההפעלה משלימה את הנתיב המלא של שם ה-exe.  2

  3. Microsoft Learn, CommandLineToArgvW function (shellapi.h). על הטיפול המיוחד ב-backslashes מיד לפני double quote (2n נותן n ועוד פתיחה או סגירה של quoting, 2n+1 נותן n ועוד quote מילולי, והם נשארים כמו שהם כשאין quote אחרי), רווחים הופכים לחלק מה-argument במצב “בתוך quotes”, שם התוכנית המוביל מותר עם או בלי quotes, ה-argument הראשון הופך למחרוזת ריקה כש-lpCmdLine מתחיל ברווח, נתיב ה-exe הנוכחי מוחזר כשמעבירים מחרוזת ריקה, וערך החזרה משוחרר ב-LocalFree אחד.  2 3 4 5

  4. Microsoft Learn, main function and command-line arguments. על הכללים שבהם קוד ההפעלה של Microsoft C/C++ מפרש את ה-command line (הפרדה ברווחים וב-tabs, argv[0] ניתן לעטיפה אבל לא כפוף לכללים הבאים, מחרוזת עטופה היא argument אחד, ה-caret אינו תו escape, שני quotes רצופים בתוך quotes הם quote אחד, הכל עד הסוף הוא ה-argument האחרון כשאין quote סוגר, והטיפול במספר זוגי ואי-זוגי של backslashes), טבלת הקלטים ו-argv, הרחבת wildcards עם setargv.obj, ו-argv[0] עשוי לא להיות שם ה-exe כשגם lpApplicationName וגם lpCommandLine צוינו, כך שיש לקבל אותו עם GetModuleFileName 2 3 4 5 6 7 8

  5. Microsoft Learn, ProcessStartInfo.ArgumentList Property. על כך שאין צורך לעשות escape מראש למחרוזות שמוסיפים, ArgumentList ו-Arguments עצמאיים ואי אפשר להשתמש בהם יחד, ArgumentList עושה escape ל-arguments ומרכיב בפנים מחרוזת אחת ומעביר אותה ל-OS ב-Process.Start, אם לא בטוחים ב-quoting עדיף לבחור ArgumentList, הסכנה בשילוב עם נתונים לא מהימנים, וההחלה מ-.NET Core 2.1 ואילך.  2 3 4

  6. dotnet/runtime (GitHub), PasteArguments.cs ו-PasteArguments.Windows.cs. קוד הבניה שבו משתמשים פנימית ב-ArgumentList. argument שאינו ריק ושאינו מכיל רווח או quote נשאר כמו שהוא; אחרת עוטפים ב-quotes, מכפילים backslashes בסוף, מכפילים-ועוד-אחד את ה-backslashes מיד לפני quote, ושמים backslash לפני כל quote; לא מייצרים את הצורה שבה quote סוגר בא אחריו quote כי VC לפני ואחרי 2008 מפרשים אותה אחרת; עבור argv[0] עוטפים ב-quotes אם יש רווח, וזורקים חריגה אם יש quote.  2 3 4 5 6

  7. Microsoft Learn, about_Parsing. על האזהרה מפני העברת קלט לא מהימן לקובצי batch, כי ה-arguments מגיעים ל-cmd.exe כמחרוזת command-line גולמית.  2

  8. Microsoft Learn, Command prompt (Cmd.exe) command-line string limitation. על מגבלת 8,191 תווים, על כך שהיא חלה גם על command lines בתוך קובץ batch, ועל שיטת המעקף של כתיבת arguments לקובץ והעברת שם הקובץ.  2 3

  9. Microsoft Learn, WinMain function (winbase.h). על כך ש-lpCmdLine הוא ה-command line בלי שם התוכנית, כל ה-command line מתקבל עם GetCommandLine, ו-wWinMain קיים כנקודת הכניסה של Unicode. 

  10. dotnet/runtime (GitHub), apphost.c ו-dotnet.cpp. נקודות הכניסה של apphost ושל dotnet.exe ב-Windows הן wmain(int argc, wchar_t* argv[]), והן מעבירות את argv שנוצר ב-C runtime כמו שהוא לטיפול ההפעלה של ה-host. 

  11. dotnet/runtime (GitHub), corhost.cpp. ExecuteAssembly בונה את המערך של Environment.GetCommandLineArgs() עם SetCommandLineArgs(pwzAssemblyPath, argc, argv); האלמנט המוביל הוא שם ההפעלה שה-host העביר (או נתיב ה-assembly אם אין), ואחריו argv; ל-Main מועבר רק argv הזה.  2

  12. dotnet/runtime (GitHub), Environment.cs ו-Environment.Windows.cs. GetCommandLineArgs מחזיר את המערך שאותחל בהפעלה (s_commandLineArgs), ובספרייה מתארחת בלי זה נופל חזרה לפיצול ערך החזרה של GetCommandLineW ב-SegmentCommandLine של ה-runtime עצמו; הכללים עוקבים אחרי התיעוד של פונקציית main של MSVC, ו-CommandLineToArgvW אינו בשימוש כי הוא מתנהג קצת אחרת.  2 3

  13. Microsoft Learn, Main() and command-line arguments. על כך ש-args ב-Main אינו null, ושבניגוד ל-C/C++ שם התוכנית אינו בראש args אלא האלמנט המוביל של GetCommandLineArgs(). 

  14. Microsoft Learn, dotnet command. על כך שהרצת אפליקציה היא בצורה dotnet [אפשרויות runtime] <נתיב האפליקציה=""> [arguments], וה-arguments שמועברים לאפליקציה הם מה שאחרי נתיב האפליקציה. 

  15. Microsoft Learn, Environment.GetCommandLineArgs Method. על כך שהאלמנט המוביל הוא שם ה-exe, ש-arguments מופרדים ברווחים ואפשר לכלול רווחים עם double quotes, של-single quotes אין את התפקיד הזה, על כללי מספר זוגי ואי-זוגי של backslashes ו-quotes, ועל טבלת הקלטים והתוצאות. 

  16. Microsoft Learn, ProcessStartInfo.Arguments Property. על כך שאורך המחרוזת קטן מ-32,699, את ה-arguments מפרשת האפליקציה היעד ולכן צריך להתאים לציפיות שלה, argument עם רווח שעטוף ב-quotes לא מעביר את ה-quotes עצמם ליעד, והוא עצמאי מ-ArgumentList 2

  17. Microsoft Learn, cmd. על כך ש-&, , ( ) הם תווים מיוחדים שדורשים ^ או quotes, רשימת התווים המיוחדים שצריך לעטוף ב-quotes, התנאים שבהם quotes נשמרים אחרי /c /k (בלי /s, זוג quotes אחד, בלי תווים מיוחדים, עם רווח, וזה שם exe), ואיך מקלפים את ה-quote המוביל כשהתנאים לא מתקיימים.

  18. Microsoft Security Response Center, MS14-019 – Fixing a binary hijacking via .cmd or .bat file ו-Microsoft Security Bulletin MS14-019. CreateProcess חיפש cmd.exe קודם בתיקייה הנוכחית כשהועברו לו .cmd / .bat ישירות, ולכן אפשר היה לחטוף אותו; אחרי התיקון הוא תמיד משתמש ב-cmd.exe של המערכת, וההמלצה היא שאפליקציות יעבירו את הנתיב המלא של cmd.exe ואת ה-batch כ-argument. 

מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.

העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.

המאמר קשור ישירות לשירותים הבאים.

שאלות נפוצות

שאלות נפוצות בפניות בנושא המאמר.

אין ב-Windows API שמעביר מערך של arguments?
אין. מה ש-CreateProcess מקבל הוא מחרוזת אחת שנקראת lpCommandLine, וזו המחרוזת שמגיעה ל-process החדש (מערכת ההפעלה עשויה להשלים את הנתיב המלא לשם ה-exe המוביל בלבד). מה שנראה כמו מערך argv נוצר בתוך process הקבלה על ידי קוד ההפעלה של C runtime, CommandLineToArgvW, או ה-runtime של .NET שמפצל את המחרוזת. העברת arguments היא לכן אותו דבר כמו בניית מחרוזת שה-parser של הצד השני יפצל בחזרה לחתיכות המקוריות.
מתי backslash הופך לתו escape?
רק כש-double quote בא מיד אחריו. backslash שלא אחריו double quote נשאר כמו שהוא, לא משנה כמה ברצף. אם 2n backslashes קודמים ל-double quote, הם הופכים ל-n backslashes וה-quote פותח או סוגר quoting; אם 2n+1 קודמים, הם הופכים ל-n backslashes ולתו quote מילולי. בגלל האסימטריה הזו, צריך להכפיל backslash בסוף נתיב רק כשעוטפים את הנתיב ב-quotes.
במה להשתמש, ProcessStartInfo.ArgumentList או Arguments?
אם הערכים מגיעים ממשתנים, ArgumentList. אלמנט אחד הופך ל-argument אחד, .NET מחיל את ה-quoting וה-escaping הנחוצים, ובונה מחרוזת אחת פנימית לפני שהוא מוסר אותה למערכת ההפעלה. Arguments הוא מאפיין שמעביר מחרוזת שבניתם בעצמכם כמו שהיא; השניים עצמאיים ואי אפשר להשתמש בהם באותו זמן. שימו לב ש-ArgumentList הוא API שהוצג ב-.NET Core 2.1 ואינו קיים ב-.NET Framework. ב-.NET Framework, בונים Arguments עם פונקציית הבניה במאמר הזה.
אפשר לכתוב שני quotes סמוכים בתוך argument עטוף?
לא מייצרים את זה בצד הבניה, כי מקבלים מפרשים אותו אחרת. הכוונה כאן היא לעטוף argument שאינו ריק ב-quotes ולשים שני quotes סמוכים בפנים. ה-"" שמייצג argument ריק (רק שני quotes) הוא דבר אחר, והוא הדרך הנכונה להעביר מחרוזת ריקה. תחת כללי C runtime של MSVC, שני quotes רצופים בתוך מחרוזת עטופה מטופלים כ-quote אחד, אבל הכללים הרשמיים של CommandLineToArgvW לא מתארים את הטיפול הזה, ומקור ה-runtime של .NET אומר במפורש שהוא לא מייצר את הצורה כי VC לפני ואחרי 2008 מפרשים אותה אחרת. כשרוצים להעביר quote כתו, שמים backslash לפניו, וכל parser נותן את אותה תוצאה.
כשנתיב ה-exe מכיל רווח, מה להעביר ל-CreateProcess כדי להיות בטוחים?
הדרך האמינה היא להעביר את הנתיב המלא של ה-exe ב-lpApplicationName וגם לשים את אותו נתיב, עטוף ב-quotes, בתחילת lpCommandLine. אם lpApplicationName הוא NULL, CreateProcess מנחש את שם ה-exe מתחילת lpCommandLine, בפיצול על רווחים. למחרוזת C:\\Program Files\\MyApp -L -S הוא קודם בודק אם C:\\Program.exe קיים, כך שאם קובץ זדוני מונח שם, הוא זה שרץ. התיעוד הרשמי קובע את הסכנה הזו במפורש ומבקש להימנע מ-NULL או לעטוף את הנתיב ב-quotes.
אותם כללים חלים גם כשמעבירים arguments לקובץ batch?
לא. קובץ batch מפורש על ידי cmd.exe, ו-cmd.exe מתייחס ל-command line כמחרוזת גולמית בלי לפצל אותה ל-arguments. סמלים כמו &, |, סוגריים ו-^ פועלים כתחביר של cmd.exe, כך ש-quoting לפי כללי CommandLineToArgvW לא הופך אותם לבטוחים. התיעוד הרשמי מזהיר מפני העברת קלט לא מהימן לקובצי batch. כותבים את הערכים לקובץ ונותנים ל-exe במורד הזרם, לא ל-batch, לקרוא אותו, או מעבירים את תוכן ה-batch ל-PowerShell או ל-exe שלכם. הכנסת הערך למשתנה סביבה גם אינה גבול, כי ברגע שה-batch מרחיב אותו כ-%VAR%, cmd.exe מפרש מחדש את הסמלים.

פרופיל הכותב

עמוד היכרות עם כותב המאמר.

Go Komura

מנהל KomuraSoft LLC

מתמחה בפיתוח תוכנה עבור Windows, ייעוץ טכני וחקירת תקלות, בעיקר בפרויקטים עם מערכות קיימות ובאגים שקשה לשחזר.

קישורים ציבוריים

חזרה לבלוג