דילוג לתוכן
New

DeepSeek V4.1 Flash free for 7 days: 10M tokens a day after a top-up of $10 or more. Until Nov 30.

How it works

SQL injection בהסבר עם OWASP Juice Shop

איך SQL injection משנה את המשמעות של שאילתה, למה שאילתות עם פרמטרים פותרות את זה, ומעבדת התחברות ב-Juice Shop עם הדגמת sqlite3 ב-Python.

ביניים8 דקות קריאהמאת NotHoly

SQL injection קורה כשאפליקציה בונה שאילתת מסד נתונים מחיבור של טקסט ה-SQL שלה לנתונים שהגיעו ממשתמש. מסד הנתונים מקבל מחרוזת אחת ואין לו דרך לדעת אילו תווים המפתח כתב ואילו המשתמש הקליד. ברגע שהטקסט של המשתמש מגיע ל-parser כ-SQL, הוא יכול לשנות את משמעות השאילתה. השאילתה יכולה להחזיר שורות אחרות, לדלג על בדיקה, לשנות נתונים, או במקרים הגרועים להגיע לדברים שהאפליקציה מעולם לא התכוונה לחשוף.

המאמר הזה מסביר את המנגנון, מראה את התיקון היחיד שמטפל בסיבה, ונותן שני חלקים מעשיים. הראשון הוא אתגר התחברות ב-OWASP Juice Shop, שרץ על המחשב שלך. השני הוא סקריפט Python קצר עם sqlite3 שמשחזר את אותו באג ואת התיקון. מי שקרא קודם את OWASP Top 10 למפתחים כבר מכיר את הקטגוריה. כאן לוקחים פריט אחד מהרשימה ויורדים עד לטקסט של השאילתה.

מה זה SQL injection

ההגדרה של CWE לחולשה הזאת, CWE-89, מתארת אותה בפשטות: התוכנה בונה חלק מפקודת SQL מתוך קלט ולא מנטרלת את תחביר ה-SQL שבקלט. הקלט מפורש אז כפקודות ולא כערכים.

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

למה זה חשוב

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

injection גם לא צריך payload מתוחכם. מפתחים מוצאים אותו לעיתים קרובות עם קלט שגורם לאפליקציה לקרוס או להחזיר שגיאה מוזרה. שגיאה שמזכירה תחביר SQL היא סימן חזק לכך שהקלט הגיע לשאילתה.

איך השאילתה משנה משמעות

מתחילים מתבנית שמפתח עשוי לכתוב:

sql
SELECT * FROM users WHERE name = '<name>' AND pw = '<pw>'

נניח ששדה השם מקבל admin' -- ושדה הסיסמה מקבל wrong. אחרי שהאפליקציה מכניסה את המחרוזות האלה לתבנית, מסד הנתונים מפרסר את זה:

sql
SELECT * FROM users WHERE name = 'admin' --' AND pw = 'wrong'

קוראים את זה שלב אחר שלב. המירכאה הראשונה אחרי admin סוגרת את המחרוזת שהמפתח פתח. אחר כך -- מתחיל הערה ב-SQLite וברוב הניבים של SQL. מסד הנתונים מתעלם מכל מה שאחריו, כולל תנאי הסיסמה. מסד הנתונים מקבל שאילתה תקינה שמבקשת את השורה שהשם בה הוא admin, ותו לא.

ארבעה דברים קורים לפי הסדר:

  1. האפליקציה מעתיקה את התווים של המשתמש לתוך טקסט השאילתה.
  2. ה-parser של מסד הנתונים קורא את כל המחרוזת כ-SQL. הוא לא יכול לדעת אילו תווים באו מהמפתח ואילו מהמשתמש.
  3. מירכאה מהמשתמש מסיימת את המחרוזת מוקדם. הטקסט שאחריה מפורסר כמילות מפתח, אופרטורים או הערות של SQL.
  4. התנאי שהמפתח כתב כבר לא התנאי שמסד הנתונים מריץ.

יש אותו פגם ב-f-string, בשרשור מחרוזות, בעיצוב עם %, בשימוש במתודה .format() וב-template literals. השפה לא משנה. מה שמשנה הוא שיטת החיבור בין טקסט ל-SQL.

התיקון בשורש: שאילתות עם פרמטרים

פרמטריזציה מפרידה את טקסט ה-SQL מהערכים. שולחים את השאילתה עם סימני מקום, והערכים עוברים למסד הנתונים דרך ה-driver כנתונים. מסד הנתונים אף פעם לא מפרסר אותם כ-SQL, כך שלמירכאה בתוך ערך אין משמעות מיוחדת.

התיעוד של sqlite3 ב-Python אומר להשתמש בסימני מקום ולא בעיצוב מחרוזות כשקושרים ערכי Python לפקודות SQL, ובמפורש כדי להימנע מ-SQL injection. OWASP SQL Injection Prevention Cheat Sheet מציין שאילתות עם פרמטרים כהגנה העיקרית מאותה סיבה: מבנה השאילתה מוגדר בנפרד מהנתונים.

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

תרגיל מעשי: פתרון האתגר Login Admin

Juice Shop היא אפליקציית web פגיעה בכוונה ש-OWASP מתחזק לצורכי הדרכה. מריצים אותה רק על המחשב שלך ומשאירים אותה קשורה לכתובת ה-loopback, כדי שאף מחשב אחר לא יוכל להגיע אליה.

הפעלת המעבדה על localhost

bash
docker run -d -p 127.0.0.1:3000:3000 bkimminich/juice-shop

הקידומת 127.0.0.1: מפרסמת את הפורט רק על ממשק ה-loopback שלך. אם פורט 3000 כבר תפוס, Docker נכשל עם שגיאה כמו port is already allocated. במחשב הבדיקה של המאמר הזה, קונטיינר מקומי אחר כבר החזיק בפורט 3000 והפקודה נכשלה בגלל זה. בוחרים פורט פנוי במארח, למשל 127.0.0.1:3001:3000, ומשתמשים בפורט הזה בשאר השלבים.

מוודאים שהאפליקציה עונה ובודקים את הגרסה שלה:

bash
curl -s http://127.0.0.1:3000/rest/admin/application-version

במחשב הבדיקה זה החזיר:

text
{"version":"20.3.0"}

איתור נקודת ההזרקה

המדריך הרשמי הנלווה ל-Juice Shop, ‏Pwning OWASP Juice Shop, לא מדפיס את ה-payload של האתגר הזה. הוא נותן רמזים: תיאור האתגר מצביע על הטופס שצריך לתקוף, התקפה ממוקדת עובדת טוב אם כתובת האימייל של האדמין כבר ידועה, ודפוס injection סטנדרטי יכול להצליח גם בלי המידע הזה. הולכים לפי הרמזים. מתחילים מטופס ההתחברות וממירכאה בודדת בשדה האימייל. סיסמה שגויה רגילה אמורה להחזיר שגיאה. שאילתה שבורה מחזירה לעיתים קרובות תגובה אחרת.

במחשב הבדיקה, סיסמה שגויה לאימייל של האדמין בלי injection החזירה:

text
Invalid email or password.

עם סטטוס HTTP 401. זה קו הבסיס. עכשיו אותה בקשה עם מירכאה והערה בשדה האימייל:

bash
curl -s -X POST http://127.0.0.1:3000/rest/user/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]'\''--","password":"wrong"}'

התגובה מכילה אובייקט authentication עם שדה token. הסיסמה הייתה שגויה, ובכל זאת השרת הנפיק טוקן סשן. פענוח החלק האמצעי של ה-JSON Web Token, שמקודד ב-base64url, מראה "role":"admin". התגובה החזירה HTTP 200 במחשב הבדיקה.

בודקים את מצב לוח התוצאות דרך ה-API של המעבדה:

bash
curl -s "http://127.0.0.1:3000/api/Challenges/?name=Login%20Admin"

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

המדריך הנלווה וקוד המקור של Juice Shop הם המקומות לאישור השאילתה המדויקת שמאחורי הטופס הזה. כדאי לקרוא אותם אחרי שיש תוצאה, כדי להבין את המנגנון ולא רק להעתיק payload.

עצירת המעבדה

bash
docker ps
docker stop <container-name>

משתמשים בשם הקונטיינר או במזהה שהפקודה docker ps מציגה. סגירת לשונית הדפדפן לא עוצרת את הקונטיינר.

שחזור הבאג והתיקון ב-Python

שומרים את זה בשם sqli_demo.py. הוא צריך רק את הספרייה הסטנדרטית. הסקריפט בונה טבלת users קטנה בזיכרון ואז חושף שתי פונקציות התחברות. אחת בונה את השאילתה עם f-string. השנייה משתמשת בסימן מקום.

python
import sqlite3

con = sqlite3.connect(":memory:")
cur = con.cursor()
cur.execute("CREATE TABLE users (name TEXT, pw TEXT)")
cur.execute("INSERT INTO users VALUES ('alice', 'correct-horse'), ('admin', 'S3cret!')")

def login_unsafe(user, pw):
    query = f"SELECT * FROM users WHERE name='{user}' AND pw='{pw}'"
    print("  built:", query)
    return cur.execute(query).fetchall()

def login_safe(user, pw):
    return cur.execute('SELECT * FROM users WHERE name=? AND pw=?', (user, pw)).fetchall()

if __name__ == "__main__":
    print("same valid login, unsafe:", login_unsafe("admin", "S3cret!"))
    print("same valid login, safe:  ", login_safe("admin", "S3cret!"))
    print("crafted input, unsafe:")
    print("  result:", login_unsafe("admin' --", "wrong"))
    print("crafted input, safe:")
    print("  result:", login_safe("admin' --", "wrong"))

מריצים אותו. ב-Windows, כש-Python מותקן בשם python, הפקודה היא python sqli_demo.py. במחשב הבדיקה python3 לא היה בנתיב, ולכן שם צריך להריץ python. הפלט שהתקבל במחשב הבדיקה עם Python 3.12.10:

text
same valid login, unsafe: [('admin', 'S3cret!')]
same valid login, safe:   [('admin', 'S3cret!')]
crafted input, unsafe:
  built: SELECT * FROM users WHERE name='admin' --' AND pw='wrong'
  result: [('admin', 'S3cret!')]
crafted input, safe:
  result: []

משווים את שני החלקים. בהתחברות רגילה שתי הפונקציות מחזירות את אותה שורה, כך שהגרסה הלא בטוחה נראית נכונה. בגלל זה הבאג הזה עובר code review. עם קלט שנבנה בכוונה, הפונקציה הלא בטוחה מדפיסה את השאילתה שמסד הנתונים קיבל. ההערה מסירה את בדיקת הסיסמה, ושורת האדמין חוזרת למרות שהסיסמה שגויה. הפונקציה הבטוחה מתייחסת לערך admin' -- כשם מילולי. למשתמש אין שם כזה, ולכן היא מחזירה רשימה ריקה.

הסקריפט שומר סיסמאות כטקסט גלוי כי זו הדגמה. מערכות אמיתיות שומרות hash עם salt ומשוות hashes.

איפה ORMs ו-helpers עדיין נכשלים

ORM לא הופך את הבאג לבלתי אפשרי. הוא נותן ברירת מחדל בטוחה לשאילתות רגילות ומסלולי עקיפה לכל השאר. אלה המקומות שבהם הבאג בדרך כלל חוזר:

  • מתודות SQL גולמי. קריאות כמו raw, ‏text, ‏extra או execute רגיל מקבלות מחרוזת. בניית המחרוזת הזאת עם f-string או שרשור מחזירה לדפוס הלא בטוח.
  • שמות טבלאות ועמודות. סימני מקום קושרים ערכים. הם לא יכולים לקשור מזהים. עמודת ORDER BY או שם טבלה שנשלטים בידי המשתמש חייבים לעבור דרך allow-list שממפה את הבחירה של המשתמש לשמות שכתבת בעצמך. OWASP ממליץ על אותה גישה לבחירות מבניות קבועות כמו כיוון המיון.
  • רשימות IN דינמיות. מספר סימני המקום משתנה עם הקלט. מייצרים מהקוד שלך את המספר הנכון של סימני ?, ואז מעבירים את הערכים כ-tuple. לא מחברים את ערכי המשתמש לתוך הטקסט.
  • helpers של סינון. פונקציה שמקבלת מחרוזת סינון ומצרפת אותה לפסוקית WHERE היא נקודת שרשור, גם כששאר האפליקציה משתמשת ב-ORM כמו שצריך.

טעויות נפוצות

רשימות שחורות של מילים. קל לעקוף מסנן שדוחה OR, ‏SELECT, מירכאות או -- בעזרת שינויי רישיות, קידודים וסגנונות הערה. ה-Cheat Sheet של OWASP ברור בכך שאימות לא מחליף קישור פרמטרים. קודם קושרים. אימות שימושי לבחירות המבניות הקבועות שלמעלה, ולא כהגנה העיקרית.

escaping ידני של מירכאות. לכל driver של מסד נתונים יש כללים משלו, ומקרה אחד שמתפספס מספיק. עדיף להשאיר את העבודה ל-driver.

הסתמכות על הרשאה מינימלית בלבד. חשבון מסד נתונים עם פחות הרשאות מגביל את מה שתוקף יכול לעשות אחרי injection. הוא לא עוצר את ה-injection. OWASP מציין הרשאה מינימלית כבקרה תומכת, ואומר שלחשבונות אפליקציה לעולם לא צריכות להיות הרשאות DBA או אדמין. עושים את שניהם: משתמשים בפרמטרים ונותנים לאפליקציה את מערך ההרשאות הקטן ביותר שהיא צריכה.

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

בדיקה של קלט רגיל בלבד. הבאג מופיע רק כשהקלט מכיל תחביר SQL. מוסיפים בדיקה ששולחת את הערך שנבנה בכוונה ומצפה לתוצאה ריקה או לבקשה שנדחתה.

איך ממשיכים

קוראים את הסעיף על סימני מקום בתיעוד של sqlite3 ב-Python, ואז מנסים את אותו דפוס עם ה-driver שהפרויקט שלך משתמש בו. קוראים את OWASP SQL Injection Prevention Cheat Sheet מההתחלה ועד הסוף, במיוחד את הסעיפים על stored procedures ועל מזהים דינמיים.

חוזרים למעבדת Juice Shop ופותרים את שאר האתגרים שקשורים ל-SQL באותה זהירות. בכל אחד מהם כותבים את השאילתה שלדעתך השרת מריץ, ואז בודקים בקוד המקור.

מוסיפים בדיקת רגרסיה לקוד שלך. הבדיקה צריכה לשלוח admin' -- או ערך דומה לכל פונקציית שאילתה שמקבלת קלט משתמש, ולוודא שהפונקציה לא מחזירה שורות שלא אמורה להחזיר.

מחפשים בקוד שלך f-strings, עיצוב עם %, ‏.format() ושרשור מחרוזות שמופיעים ליד execute, ‏raw, ‏text או extra. כל התאמה היא מקום לבדיקה, ולא בהכרח באג.

כלי ניתוח סטטי יכולים לסמן חלק מהדפוסים האלה, ומודל שפה גדול יכול לעזור לקרוא בוני שאילתות לא מוכרים. אף אחד מהם לא מחליף את הבדיקה שמראה שהשאילתה שלך מתנהגת נכון. ה-API של NotHoly הוא אפשרות אחת לסקירה כזאת, והתועלת שלו תלויה בבדיקה שמאמתת את התוצאה.

המאמר הבא בסדרה עוסק ב-cross-site scripting (XSS), שיש לו מבנה דומה: טקסט לא מהימן מפורש בסוף כמשהו שהוא לא אמור להיות.

מקורות

מקורות

  1. OWASP SQL Injection Prevention Cheat Sheet
  2. MITRE CWE-89: Improper Neutralization of Special Elements used in an SQL Command
  3. OWASP Pwning OWASP Juice Shop companion guide
  4. Python sqlite3 DB-API 2.0 interface for SQLite databases

עוד בנושא אבטחת Web ו-API

אבטחת Web ו-API

קריאת JavaScript בעזרת AI כדי למצוא endpoints

ה-JavaScript בצד הלקוח מפרט את ה-API שהאפליקציה מדברת איתו. בעזרת מודל AI קוראים bundles ומעלים endpoints ופרמטרים, ואז מאמתים כל אחד בעצמך.

מתחילים2 דקות קריאה

אבטחת Web ו-API

בדיקת באגים נפוצים בווב בעזרת AI

מתודולוגיה ל-IDOR, XSS, SSRF ו-auth שבור עם מודל AI כקורא וכדעה שנייה, בתוספת איך מאמתים finding ונמנעים מ-false positives.

מתחילים3 דקות קריאה

אבטחת Web ו-API

בדיקת API בעזרת AI: OpenAPI ו-GraphQL

APIs חושפים את הצורה שלהם דרך OpenAPI specs ו-GraphQL introspection. בעזרת מודל AI קוראים את ה-schema ומתכננים בדיקות authorization שאתה מאמת.

ביניים3 דקות קריאה