היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 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”, כך שהמאמר הזה חופר בשכבה שמתחת.
flowchart TB
accTitle: השכבה שהמאמר הזה מכסה
accDescr: העברת arguments ב-PowerShell מכוסה במאמר נפרד; המאמר הזה מכסה את השכבה שמתחת, מ-CreateProcess של Win32 ומ-ProcessStartInfo של .NET עד ה-parser של ה-exe היעד
ps["העברת arguments ב-PowerShell (מאמר נפרד)"] --> net["ProcessStartInfo של .NET"]
net --> win["CreateProcessW של Win32"]
win --> str["מחרוזת command-line אחת"]
str --> parser["ה-parser של ה-exe היעד"]
net -.->|"היקף המאמר הזה"| parser
איור 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
flowchart TB
accTitle: הנתיב ש-arguments עוברים עד שהם מגיעים ל-child process
accDescr: מערך ה-arguments של הקורא מחובר למחרוזת אחת ב-lpCommandLine של CreateProcess ומועבר ל-process החדש, ו-child process מפצל את המחרוזת שהוא שולף עם GetCommandLineW באמצעות ה-parser שלו כדי ליצור argv
arr["מערך ה-arguments של הקורא"] --> join["מחובר למחרוזת אחת (אחריות הקורא)"]
join --> cp["lpCommandLine של CreateProcessW"]
cp --> peb["פרמטרי ה-process של ה-process החדש"]
peb --> gcl["המחרוזת ש-GetCommandLineW מחזיר"]
gcl --> parse["ה-parser של צד הקבלה מפצל"]
parse --> argv["מערך 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 יומיומיים לעתים נדירות פוגעים בהם, אבל ההנחה ש”הכללים זהים, אז הכל הולך” היא מה שנשבר בקצוות.
flowchart TB
accTitle: שלושת ה-parsers בצד הקבלה
accDescr: המחרוזת האחת ש-GetCommandLineW מחזיר מפוצלת על ידי קוד ההפעלה של C runtime ל-C/C++, על ידי CommandLineToArgvW לשימוש Win32 ישיר, ועל ידי קוד הפיצול של ה-runtime עצמו ל-.NET שנטען כספרייה מתארחת; כל אחד עוקב אחרי כללים עם אותו שלד אבל הוא מימוש נפרד. אפליקציית .NET רגילה שמופעלת דרך apphost או dotnet.exe מקבלת את המערך שפוצל על ידי קוד ההפעלה של C runtime של ה-host
s["המחרוזת של GetCommandLineW"] --> crt["קוד ההפעלה של C runtime"]
s --> api["CommandLineToArgvW"]
s --> net["קוד הפיצול של .NET עצמו (כשנטען על ידי host)"]
crt --> app[".NET דרך apphost / dotnet.exe זה אותו דבר"]
crt --> same["אותו שלד כללים, מימושים נפרדים"]
api --> same
net --> same
איור 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
- Arguments מופרדים ב-רווחים או tabs.
- אזור עטוף ב-double quotes הופך ל-argument אחד גם אם הוא מכיל רווחים. ה-quotes עצמם אינם חלק מה-argument. quote יכול להתחיל באמצע argument, ואם המחרוזת מסתיימת בלי quote סוגר, הכל עד הסוף הופך ל-argument האחרון.
- backslash מטופל כתו רגיל. רק כש-double quote בא מיד אחריו חלים הכללים הבאים.
- אם 2n backslashes קודמים ל-double quote, n backslashes יוצאים, וה-quote פועל כ”התחלה או סוף של quoting”.
- אם 2n+1 backslashes קודמים ל-double quote, n backslashes ו-quote מילולי יוצאים, ומצב ה-quoting לא משתנה.
- ה-caret (
^) אינו תו escape (זה כלל של cmd.exe, לא כלל parser).
ה-parser שומר ביט מצב אחד, “האם אני בתוך quotes”, הופך אותו בכל quote, וקורא את המחרוזת משמאל לימין. אם רווח מפריד arguments נקבע לפי המצב הזה.
flowchart TB
accTitle: זרימת הפיצול שעוברת בין בפנים ומחוץ ל-quotes
accDescr: מחוץ ל-quotes ה-parser מפריד arguments ברווחים; כשהוא פוגש quote הוא נכנס פנימה ומתייחס לרווחים כחלק מה-argument; כשהוא פוגש quote נוסף הוא חוזר החוצה. backslash מטופל באופן מיוחד רק כש-quote בא מיד אחריו
out["מחוץ ל-quotes: פיצול ברווחים"] -->|"פוגשים quote"| inq["בתוך quotes: רווחים הם חלק מה-argument"]
inq -->|"פוגשים quote"| out
out -->|"backslash ואחריו מיד quote"| bs["מחילים את כלל ה-backslash"]
inq -->|"backslash ואחריו מיד quote"| bs
bs -->|"2n: מוציאים n ופותחים/סוגרים"| toggle["הופכים את מצב ה-quoting"]
bs -->|"2n+1: מוציאים n ו-quote מילולי"| lit["שומרים על מצב ה-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 אף פעם לא נסגר.
flowchart TB
accTitle: איך backslash בסוף בולע את ה-argument הבא
accDescr: כשנתיב עם backslash בסוף עטוף ב-quotes, ה-quote שאמור לסגור יושב מיד אחרי backslash יחיד ומפורש כ-quote מילולי, כך ש-quoting אף פעם לא נסגר והכל עד ה-argument הבא נקרא כ-argument אחד
a["נתיב עטוף שמסתיים ב-backslash אחד"] --> b["מספר אי-זוגי של backslashes קודם ל-quote הסוגר"]
b --> c["ה-quote יוצא כתו ו-quoting לא נסגר"]
c --> d["רווחים הבאים כבר לא מפרידים"]
d --> e["הכל עד ה-argument הבא מגיע כ-argument אחד"]
a -.->|"מכפילים את ה-backslash"| ok["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
flowchart TB
accTitle: הסדר שבו מנחשים את ה-exe כש-lpApplicationName הוא NULL
accDescr: אם C:\Program Files\MyApp -L -S מועבר בלי quotes, CreateProcess בודק קודם C:\Program.exe ואחר כך C:\Program Files\MyApp.exe בסדר הזה, כך שאם C:\Program.exe הונח שם, הוא זה שרץ
in["מעבירים נתיב לא עטוף (שמכיל רווחים) ב-lpCommandLine"] --> t1["מועמד 1: מנסים C:\\Program.exe"]
t1 -->|"קיים"| bad["exe לא מכוון מתחיל"]
t1 -->|"לא קיים"| t2["מועמד 2: מנסים C:\\Program Files\\MyApp.exe"]
t2 --> ok["ה-exe המכוון מתחיל"]
in -.->|"מעבירים lpApplicationName, או עוטפים את הטוקן המוביל"| ok
איור 6: הנחת נתיב עם רווחים בהתחלה בלי quotes גורמת למערכת ההפעלה לנסות מועמדים מהקצר. התיעוד הרשמי קורא לזה “dangerous” במפורש.
התיעוד הרשמי קובע שאם C:\Program.exe מונח שם, הוא רץ במקום האפליקציה המכוונת, ומבקש לא להעביר NULL ל-lpApplicationName, ואם כן, לעטוף את הנתיב המוביל ב-quotes.1 בפועל, עושים את שניהם. מעבירים את הנתיב המלא של ה-exe ב-lpApplicationName, וגם שמים את אותו נתיב, עטוף ב-quotes, בתחילת lpCommandLine. כששניהם מועברים, המודול שרץ נקבע על ידי lpApplicationName, ו-argv[0] של ה-child process הופך לטוקן המוביל של lpCommandLine. אלא אם שומרים על השניים עקביים לפי מוסכמה, קוד שגוזר את הנתיב שלו מ-argv[0] נשבר. הדרך האמינה לקבל את הנתיב שלכם היא GetModuleFileNameW.4
flowchart TB
accTitle: איך המודול שרץ ו-argv[0] נקבעים
accDescr: כשגם lpApplicationName וגם lpCommandLine מועברים, המודול שרץ נקבע על ידי lpApplicationName ו-argv[0] של ה-child הוא הטוקן המוביל של lpCommandLine. קוד שגוזר את הנתיב שלו מ-argv[0] נשבר כשהשניים מתפצלים, לכן מקבלים את הנתיב שלכם עם GetModuleFileNameW
app["lpApplicationName"] --> run["המודול שרץ"]
cl["הטוקן המוביל של lpCommandLine"] --> a0["argv[0] של ה-child"]
a0 -.->|"נשבר כשהם מתפצלים"| self["קוד שגוזר את הנתיב שלו מ-argv[0]"]
self -.->|"משתמשים במקום"| gmf["GetModuleFileNameW"]
איור 7: “מה רץ” ו”מה נכנס ל-argv[0]” נקבעים בנפרד. תכנון שגוזר את הנתיב שלו מ-argv[0] לא יכול לעמוד מעל ההפרדה הזו.
נקודה נוספת: כש-lpApplicationName הוא NULL, חלק שם ה-exe של lpCommandLine מוגבל ל-MAX_PATH.1 לטיפול בנתיבים ארוכים, ראו “MAX_PATH ומלכודות נתיב/שם קובץ ב-Windows”.
6. הכללים בצד הבניה — פונקציה אחת מספיקה
ברגע שיודעים את כללי הפיצול, אפשר לבנות “מחרוזת שהצד השני יפצל בחזרה למקור” פשוט בהרצתם הפוכה. לכל argument מ-argv[1] ואילך, עושים את הבא.6
- אם הוא לא ריק ואינו מכיל לא רווחים ולא quotes, שמים אותו כמו שהוא.
- אחרת, עוטפים את הכל ב-quotes. בתוך ה-quoting,
- הופכים ריצה של k backslashes מיד לפני quote ל-2k+1 ואז שמים את ה-quote (הפיכת המספר לאי-זוגי הופכת אותו ל-“quote מילולי”);
- הופכים ריצה של k backslashes בסוף ל-2k (הם קודמים ל-quote הסוגר, כך שמספר זוגי הופך אותו ל-“סוף quoting”);
- משאירים כל backslash אחר כמו שהוא.
- שמים מחרוזת ריקה כ-
"".
flowchart TB
accTitle: זרימת ההחלטה לבניית argument אחד
accDescr: אם ה-argument אינו ריק ואינו מכיל לא רווחים ולא quotes, שמים אותו כמו שהוא; אחרת עוטפים ב-quotes, הופכים backslashes לפני quote ל-2k+1 ו-backslashes בסוף ל-2k, שמים backslash לפני quotes, וסוגרים
s["מקבלים argument אחד"] --> q{"ריק, או מכיל רווח או quote?"}
q -->|"לא"| raw["שמים כמו שהוא"]
q -->|"כן"| open["quote פותח"]
open --> scan["סורקים משמאל"]
scan --> bq["k backslashes לפני quote → 2k+1"]
scan --> be["k backslashes בסוף → 2k"]
scan --> other["כל השאר כמו שהוא"]
bq --> close["quote סוגר"]
be --> close
other --> close
איור 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
flowchart TB
accTitle: איפה ArgumentList ו-Arguments הופכים למחרוזת
accDescr: עם ArgumentList, .NET עושה escape לכל אלמנט ובונה מחרוזת אחת לפני שמעביר ל-CreateProcess; עם Arguments, המחרוזת שהקורא בנה מועברת כמו שהיא. בכל מקרה, מה שמגיע למערכת ההפעלה הוא מחרוזת אחת
al["ArgumentList (1 אלמנט = 1 argument)"] --> esc[".NET עושה escape לכל אלמנט ומחבר"]
ar["Arguments (מחרוזת אחת שבניתם בעצמכם)"] --> pass["כמו שהיא"]
esc --> cmd["מחרוזת command-line אחת"]
pass --> cmd
cmd --> cp["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\ מגיע נכון.
flowchart TB
accTitle: בחירת שיטת הבניה לפי גרסת .NET
accDescr: ב-.NET Core 2.1 ואילך, משאירים ל-ProcessStartInfo.ArgumentList; ב-.NET Framework, בונים את מחרוזת Arguments בפונקציה עצמאית באותם כללים. באף אחד מהם לא כותבים quotes ביד עם חיבור מחרוזות
v{"איזו גרסת .NET?"}
v -->|"Core 2.1 ואילך"| al["מוסיפים ל-ArgumentList אלמנט אחד בכל פעם"]
v -->|"Framework"| own["בונים Arguments בפונקציה עצמאית"]
al --> no["אף פעם לא כותבים quotes ביד"]
own --> no
איור 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 אחד לכל הפעלה
flowchart TB
accTitle: חלוקת התפקידים בין שני ה-arguments שמועברים ל-CreateProcessW
accDescr: lpApplicationName מקבע את המודול להרצה, ו-lpCommandLine קובע את המחרוזת ש-child process מקבל דרך GetCommandLineW. מעבירים lpCommandLine כ-buffer בר-כתיבה ושומרים את argv[0] המוביל עקבי עם lpApplicationName
app["lpApplicationName: הנתיב המלא של ה-exe"] --> mod["המודול להרצה מקובע"]
cl["lpCommandLine: buffer בר-כתיבה"] --> child["המחרוזת שה-child מקבל דרך GetCommandLineW"]
child --> a0["טוקן מוביל = argv[0]"]
a0 -.->|"שומרים עקבי"| app
child --> rest["השאר = 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.
flowchart TB
accTitle: cmd.exe באמצע מוסיף שלבי פרשנות
accDescr: הפעלת ה-exe היעד ישירות פירושה שהפיצול קורה פעם אחת, ב-parser של היעד, אבל מעבר דרך cmd.exe /c מוסיף את פרשנות התחביר של cmd.exe, וקובץ batch מעל זה מקבל את המחרוזת הגולמית, כך שכללי ה-quoting משתנים בכל שלב
direct["ה-process שלכם → ה-exe היעד"] --> p1["פיצול פעם אחת, רק על ידי ה-parser של היעד"]
p1 ~~~ via
via["ה-process שלכם → cmd.exe /c → ה-exe היעד"] --> p2["פרשנות התחביר של cmd.exe מתווספת (ampersand, pipe, סוגריים, caret)"]
p2 --> p3["פיצול על ידי ה-parser של היעד"]
p3 ~~~ bat
bat["ה-process שלכם → cmd.exe /c → קובץ batch"] --> p4["קובץ ה-batch מקבל את המחרוזת הגולמית"]
p4 --> danger["העברת ערכים לא מהימנים דרכו הופכת ל-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 מדף שאין אפשרות לשנות, האפשרות היחידה היא לפצל את הקריאות כך שכל אחת נכנסת במגבלה.
flowchart TB
accTitle: מגבלות העברת ערכים באורך משתנה כ-arguments, והדרך סביבן
accDescr: הנחת ערכים באורך משתנה כמו רשימת קבצים כ-arguments מגיעה למגבלת cmd.exe של 8191 תווים או למגבלת CreateProcess של 32767 יחידות קוד UTF-16 כשהמספר גדל. אם היעד יכול לקרוא response file (או אפשר לתקן אותו), עוברים לשיטת response-file של כתיבת הערכים לקובץ והעברת הנתיב בלבד; אם היעד הוא exe מדף שאי אפשר, מפצלים את הקריאות
list["מניחים ערכים באורך משתנה (רשימת קבצים וכו') כ-arguments"] --> grow["המחרוזת גדלה כשהמספר גדל"]
grow --> lim["מגיעים למגבלה (cmd.exe 8,191 / CreateProcess 32,767)"]
lim --> fail["יום אחד ההפעלה נכשלת פתאום"]
fail -.->|"היעד יכול לקרוא response file"| resp["כותבים את הערכים לקובץ ומעבירים רק את הנתיב (response file)"]
fail -.->|"exe מדף שאי אפשר"| split["מפצלים את הקריאות"]
איור 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 לפני הרישום, או מאפשרים רישום של המחרוזת הגולמית רק במצב אבחון מוגבל.
- בצד הקורא, רושמים את המחרוזת שבניתם. זה ה-
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 (אלמנט מצונזר כבר לא תואם את מחרוזת היעד, כך שמוציאים את האלמנט הזה מההשוואה). - מכינים 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
- מסתכלים על ה-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 בפועל”.
- בהפעלה של האפליקציה שלכם, רושמים את ה-command line שהיא קיבלה. כשמישהו בשטח אומר “זה לא עולה”, כשיש רשומה של המחרוזת שבה זה הופעל אפשר לבודד קודם אם זו בעיית argument. גם כאן, לא שומרים את ערך החזרה של
GetCommandLineWכמו שהוא. שומרים על ההבטחה מהפתיחה: או מפרשים את ה-arguments המפוצלים ומצנזרים ערכים שיכולים להיות secret לפני הרישום, או מאפשרים רישום של המחרוזת הגולמית רק במצב אבחון מוגבל.
סדר ההשוואה הוא כדלקמן. קודם משווים את מחרוזת הקורא (פריט 1) עם מחרוזת היעד (פריט 3 או 4). אם הן לא תואמות מלבד שם ה-exe המוביל, שלב באמצע שינה אותה: cmd.exe או קובץ batch להפעלה ישירה (פרק 9), או שיוך הקבצים של ה-shell ל-UseShellExecute = true (פרק 7). החלפת הקוד שלכם בפונקציה של פרק 6 לא תתקן את זה. אם הן תואמות, משווים את המחרוזת הזו עם המערך אחרי פיצול (פריט 2). אם הוא מפוצל לפי הכללים אבל אינו המערך שרוצים, הבעיה בצד הבניה; אם הוא לא מפוצל לפי הכללים, הבעיה היא ה-parser של המקבל.
flowchart TB
accTitle: הסדר לבידוד בעיית argument
accDescr: קודם משווים את לוג המחרוזת שנבנתה בצד הקורא עם מחרוזת היעד כפי שנראית ב-Process Explorer או בלוג ה-startup של היעד. אם הן לא תואמות מלבד שם ה-exe המוביל, שלב באמצע (cmd.exe או קובץ batch להפעלה ישירה, שיוך הקבצים של ה-shell ל-UseShellExecute=true) שינה אותה. אם הן תואמות, משווים עם המערך אחרי פיצול; אם הוא מפוצל לפי הכללים אבל אינו המערך שרוצים, הבעיה בצד הבניה, ואם הוא לא מפוצל לפי הכללים, הבעיה היא ה-parser של המקבל
s["ה-arguments שגויים"] --> caller["מסתכלים על המחרוזת שנבנתה בצד הקורא (לוג הקורא)"]
caller --> target["מסתכלים על מחרוזת היעד (Process Explorer / לוג ה-startup של היעד)"]
target --> same{"האם הן תואמות מלבד שם ה-exe המוביל?"}
same -->|"לא"| mid["שלב באמצע שינה אותה (ראו פרקים 9 ו-7)"]
same -->|"כן"| arr["מסתכלים על המערך אחרי פיצול (exe תצוגה על אותו runtime כמו היעד)"]
arr --> cmp{"האם המחרוזת והמערך תואמים לפי הכללים?"}
cmp -->|"כן: מפוצל, אבל לא המערך שרוצים"| build["בעיה בצד הבניה: מחליפים בפונקציה של פרק 6"]
cmp -->|"לא: לא מפוצל לפי הכללים"| recv["בעיה ב-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 בסוף” אף פעם לא קורים.
flowchart TB
accTitle: חמש הבטחות שמונעות כשלי arguments
accDescr: מעבירים את הנתיב המלא של ה-exe ב-lpApplicationName ועוטפים גם את הטוקן המוביל ב-quotes, משאירים quoting לפונקציה שעוקבת אחרי הכללים או ל-ArgumentList, אף פעם לא מייצרים את הצורה עם quotes סמוכים בתוך argument שאינו ריק, אף פעם לא מעבירים ערכים מבחוץ דרך cmd.exe או קובץ batch, ומשתמשים ב-response file ל-arguments שגדלים עם המספר רק כשהיעד יכול לקרוא אחד. בהנחה שהיעד מפרש לפי כללי הפיצול שפורסמו ולא הפעיל הרחבת wildcards, חמש הנקודות האלה מונעות את הכשלים שנגרמים מנתיבים עם רווחים ומ-backslashes בסוף
r0["מעבירים את הנתיב המלא ב-lpApplicationName ועוטפים גם את הטוקן המוביל"]
r1["משאירים quoting לפונקציה לפי הכללים או ל-ArgumentList"]
r2["אף פעם לא מייצרים quotes סמוכים בתוך quoting"]
r3["אף פעם לא מעבירים ערכים מבחוץ דרך cmd.exe או קובץ batch"]
r4["משתמשים ב-response file ל-arguments שגדלים (כשהיעד יכול לקרוא)"]
goal["אין כשלים מרווחים או מ-backslashes בסוף"]
r0 ~~~ r1 ~~~ r2 ~~~ r3 ~~~ r4
r0 --> goal
r1 --> goal
r2 --> goal
r3 --> goal
r4 --> goal
איור 15: כל אחת מחמש ההבטחות היא ניסוח מחדש של “מקבעים את המודול להרצה, ומעבירים רק מחרוזות שה-parser של היעד יכול לפצל”. ההנחה היא שהיעד מפרש לפי כללי הפיצול שפורסמו ולא הפעיל הרחבת wildcards (פרקים 6 ו-8); מעל זה, חמש הנקודות האלה מונעות את הכשלים שנגרמים מרווחים ומ-backslashes בסוף.
כשדברים לא עובדים, לפני שמוסיפים escapes בניחוש, מסתכלים על שלושת הדברים: המחרוזת שנבנתה בצד הקורא, המחרוזת שהגיעה לצד היעד, והמערך אחרי פיצול. אם מחרוזות הקורא והיעד נבדלות, שלב באמצע אחראי (cmd.exe או קובץ batch, או שיוך הקבצים של ה-shell ל-UseShellExecute = true); אם הן זהות, ההתאמה בין המחרוזת למערך מחליטה אם זה צד הבניה או צד הקבלה.
מאמרים קשורים
- קריאה נכונה ל-EXEs חיצוניים מ-PowerShell — מלכודות quoting של arguments, קודי יציאה ו-mojibake
- רשימת בדיקה לניהול בטוח של child processes באפליקציית Windows
- מה נשאר אחרי שה-parent מת — מחזיקים child processes ב-Job Object
- MAX_PATH ומלכודות נתיב/שם קובץ ב-Windows — מגבלת 260 תווים, שמות שמורים, נקודות בסוף, ורגישות לאותיות
- אינטגרציית shell ב-Windows היום — תפריטי הקשר, שיוכי קבצים, ומה השתנה ב-Windows 11
- מניעת instances מרובים של אפליקציית Windows — Mutexes עם שם והפעלת החלון הקיים בהפעלה שנייה
- קריאה בטוחה ל-Win32 APIs מ-C# — מדריך P/Invoke מעשי (DllImport / LibraryImport / CsWin32)
- האם קובץ ה-batch הזה צריך לעבור ל-PowerShell? — סקר נכסי cmd/bat והחלטת המעבר
תחומי ייעוץ קשורים
KomuraSoft LLC מטפלת בתכנון אפליקציות Windows שמשלבות כלים חיצוניים ו-EXEs פנימיים, בחקירת שורש של הפעלות child-process ש”עולות בחלק מהסביבות ולא באחרות”, ובסקירות של קוד הפעלת process כחלק ממעבר מ-.NET Framework ל-.NET. אפשר ליצור קשר גם על מקרה בודד של “ה-arguments מתקלקלים”.
- Windows Custom Software Development
- חקירת תקלות ואיתור גורמים
- שימוש חוזר בנכסים קיימים והעברתם
- ייעוץ טכני וסקירת תכנון
- יצירת קשר
קישורים
-
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 -
Microsoft Learn, GetCommandLineW function (processenv.h). על כך שהוא מחזיר את מחרוזת ה-command-line של ה-process הנוכחי, ערך החזרה לא לשחרר ולא לשנות, ניתן להמרה לצורה של argv דרך
CommandLineToArgvW, ועשוי לא להתאים למחרוזת שה-parent העביר ל-CreateProcessכי מערכת ההפעלה משלימה את הנתיב המלא של שם ה-exe. ↩ ↩2 -
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 -
Microsoft Learn,
mainfunction 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 -
Microsoft Learn, ProcessStartInfo.ArgumentList Property. על כך שאין צורך לעשות escape מראש למחרוזות שמוסיפים,
ArgumentListו-Argumentsעצמאיים ואי אפשר להשתמש בהם יחד,ArgumentListעושה escape ל-arguments ומרכיב בפנים מחרוזת אחת ומעביר אותה ל-OS ב-Process.Start, אם לא בטוחים ב-quoting עדיף לבחורArgumentList, הסכנה בשילוב עם נתונים לא מהימנים, וההחלה מ-.NET Core 2.1 ואילך. ↩ ↩2 ↩3 ↩4 -
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
-
Microsoft Learn, about_Parsing. על האזהרה מפני העברת קלט לא מהימן לקובצי batch, כי ה-arguments מגיעים ל-cmd.exe כמחרוזת command-line גולמית. ↩ ↩2
-
Microsoft Learn, Command prompt (Cmd.exe) command-line string limitation. על מגבלת 8,191 תווים, על כך שהיא חלה גם על command lines בתוך קובץ batch, ועל שיטת המעקף של כתיבת arguments לקובץ והעברת שם הקובץ. ↩ ↩2 ↩3
-
Microsoft Learn, WinMain function (winbase.h). על כך ש-
lpCmdLineהוא ה-command line בלי שם התוכנית, כל ה-command line מתקבל עםGetCommandLine, ו-wWinMainקיים כנקודת הכניסה של Unicode. ↩ -
dotnet/runtime (GitHub), apphost.c ו-dotnet.cpp. נקודות הכניסה של apphost ושל dotnet.exe ב-Windows הן wmain(int argc, wchar_t* argv[]), והן מעבירות את argv שנוצר ב-C runtime כמו שהוא לטיפול ההפעלה של ה-host. ↩
-
dotnet/runtime (GitHub), corhost.cpp. ExecuteAssembly בונה את המערך של Environment.GetCommandLineArgs() עם SetCommandLineArgs(pwzAssemblyPath, argc, argv); האלמנט המוביל הוא שם ההפעלה שה-host העביר (או נתיב ה-assembly אם אין), ואחריו argv; ל-Main מועבר רק argv הזה. ↩ ↩2
-
dotnet/runtime (GitHub), Environment.cs ו-Environment.Windows.cs. GetCommandLineArgs מחזיר את המערך שאותחל בהפעלה (s_commandLineArgs), ובספרייה מתארחת בלי זה נופל חזרה לפיצול ערך החזרה של GetCommandLineW ב-SegmentCommandLine של ה-runtime עצמו; הכללים עוקבים אחרי התיעוד של פונקציית main של MSVC, ו-CommandLineToArgvW אינו בשימוש כי הוא מתנהג קצת אחרת. ↩ ↩2 ↩3
-
Microsoft Learn, Main() and command-line arguments. על כך ש-args ב-Main אינו null, ושבניגוד ל-C/C++ שם התוכנית אינו בראש args אלא האלמנט המוביל של GetCommandLineArgs(). ↩
-
Microsoft Learn, dotnet command. על כך שהרצת אפליקציה היא בצורה dotnet [אפשרויות runtime] <נתיב האפליקציה=""> [arguments], וה-arguments שמועברים לאפליקציה הם מה שאחרי נתיב האפליקציה.נתיב> ↩
-
Microsoft Learn, Environment.GetCommandLineArgs Method. על כך שהאלמנט המוביל הוא שם ה-exe, ש-arguments מופרדים ברווחים ואפשר לכלול רווחים עם double quotes, של-single quotes אין את התפקיד הזה, על כללי מספר זוגי ואי-זוגי של backslashes ו-quotes, ועל טבלת הקלטים והתוצאות. ↩
-
Microsoft Learn, ProcessStartInfo.Arguments Property. על כך שאורך המחרוזת קטן מ-32,699, את ה-arguments מפרשת האפליקציה היעד ולכן צריך להתאים לציפיות שלה, argument עם רווח שעטוף ב-quotes לא מעביר את ה-quotes עצמם ליעד, והוא עצמאי מ-
ArgumentList. ↩ ↩2 -
Microsoft Learn, cmd. על כך ש-&, , ( ) הם תווים מיוחדים שדורשים ^ או quotes, רשימת התווים המיוחדים שצריך לעטוף ב-quotes, התנאים שבהם quotes נשמרים אחרי /c /k (בלי /s, זוג quotes אחד, בלי תווים מיוחדים, עם רווח, וזה שם exe), ואיך מקלפים את ה-quote המוביל כשהתנאים לא מתקיימים. -
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. ↩
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
מה נשאר אחרי שה-parent מת — מחזיקים child processes ב-Job Object
למה SDK helpers שורדים UI שנהרג ומחזיקים את המצלמה או את ה-COM port? מתכננים משך חיים של child process עם Job Objects, KillOnJobClose ו-c...
Named Pipes בפועל — ה-IPC הסטנדרטי של Windows, מתכנון עד אבטחה
מדריך מעשי ל-Named Pipes, ה-IPC הסטנדרטי ב-Windows. המאמר מסדר לפי מקורות ראשוניים את הבחירה בין byte mode ל-message mode, תכנון server ל...
רשימת בדיקה לניהול בטוח של child processes באפליקציית Windows
בניהול בטוח של child processes באפליקציית Windows, בעלות על עץ התהליכים ותהליך הסיום חשובים יותר מבחירת API להפעלה. המאמר עובר על Job Obj...
Time Travel Debugging — להקליט ולהריץ אחורה באגים שלא משתחררים באפליקציות ארוכות-ריצה
באג פעם בחודש משאיר ב-crash dump רק את התוצאה. מקליטים ומריצים אחורה את הביצוע עם WinDbg Time Travel Debugging (TTD): TTD.exe, ring buffe...
Win32 Thread Pool API — מקביליות בלי CreateThread, דרך CreateThreadpoolWork
מפזרים קריאות CreateThread בכל הקוד ה-native? המאמר מסביר את ה-Win32 Thread Pool API שעוצב מחדש ב-Vista: ארבעת האובייקטים work, timer, wa...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
יישומים עסקיים, חיבור התקנים וכלי תקשורת, מהגדרת הדרישות ועד הפיתוח.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- אין ב-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 מפרש מחדש את הסמלים.