guest@websec ~ % man 04-xss-and-other-injection
04 · XSS and Other Injection
หัวข้อ 02 พูดถึง SQL ไปแล้ว บั๊กแบบเดียวกันเกิดได้ทุกที่ที่ข้อมูลถูกรันเป็นโค้ด: HTML และ JavaScript (XSS), CSS, Markdown, คำสั่ง shell และ HTTP header ทางแก้ขึ้นกับว่าข้อมูลไปตกอยู่ที่ไหนเสมอ
4.1 Allowlist เทียบกับ blocklist
ระบุสิ่งที่ อนุญาต เสมอ ไม่ใช่สิ่งที่ห้าม blocklist ต้องเดาการโจมตีทุกแบบในโลก ส่วน allowlist แค่บอกว่าอะไรปกติ
- บังคับล็อกอินทุกหน้าเป็นค่าเริ่มต้น แล้วค่อยระบุหน้าสาธารณะไม่กี่หน้า ไม่ใช่กลับกัน Rails:
before_action :require_login, except: [:index] - อนุญาตแท็ก HTML ที่ปลอดภัยไม่กี่ตัว แทนการพยายามตัด
<script>ออก - อย่า "ซ่อม" input ที่ไม่ดี ให้ปฏิเสธไปเลย ตัด
<script>ออกจาก<sc<script>ript>แล้วคุณจะได้ตัวใหม่
blocklist: ลบ "<script>" / "</script>" หนึ่งรอบ
allowlist: escape ทุกอย่าง ไม่อนุญาตอะไรเลย
4.2 Cross-site scripting (XSS)
XSS คือ JavaScript ของผู้โจมตีได้รันบนเว็บ ของเรา ในเบราว์เซอร์ของคนอื่น ทำได้ทุกอย่างที่ผู้ใช้คนนั้นทำได้ เป็นหนึ่งในบั๊กเว็บที่พบบ่อยที่สุด และชนะการป้องกัน CSRF ด้วย เพราะสคริปต์อ่าน CSRF token จากหน้าเว็บได้
ช่องทางเข้า
อะไรก็ตามที่ผู้ใช้มีส่วนกำหนดแล้วไปโผล่บนหน้า: ฟิลด์ฟอร์ม พารามิเตอร์ URL ชื่อไฟล์ แม้แต่ header ที่แสดงในหน้า admin มีสามแบบ:
- Stored: ถูกบันทึกไว้ (เช่น ความคิดเห็น) แล้วรันกับทุกคนที่เปิดดู
- Reflected: ซ่อนในลิงก์ รันกับใครก็ตามที่กด
- DOM-based: JavaScript ของเราเองคัดลอก input ของผู้โจมตีลงในหน้า เซิร์ฟเวอร์จึงไม่เคยเห็น (อธิบายด้านล่าง)
สคริปต์ที่ถูกแทรกทำอะไรได้บ้าง
ขโมยคุกกี้ ถ้าไม่ได้ตั้ง HttpOnly และแม้ตั้งแล้ว สคริปต์ก็ยังทำงานในนามผู้ใช้ได้
ปลอมหน้าเว็บ: วางกล่องล็อกอินปลอมบนโดเมนจริงของเรา
ลองพิมพ์ความคิดเห็นในฐานะผู้โจมตี เราจะแสดงว่าเบราว์เซอร์จะทำอะไรกับมัน โดยไม่รันจริง
ดิบ: element.innerHTML = comment
escape: เทมเพลต auto-escape
sanitize: allowlist b, i, a[href=https]
ผลการแสดงผล
วิธีป้องกัน
- escape ตอนแสดงผล เทมเพลตทำให้อยู่แล้ว บั๊กมาจากช่องทางเลี่ยง:
raw,html_safe,innerHTML - ให้ตรงบริบท อย่าใส่ข้อมูลใน
<script>หรือonclickให้ใช้ attributedata-* - ต้องรับ HTML จากผู้ใช้? sanitize ด้วยไลบรารี (
sanitizeของ Rails, DOMPurify) ห้ามใช้ regex - ตรวจ URL ว่าขึ้นต้นด้วย http(s)
- เพิ่ม CSP เป็นตาข่ายรองรับ
<%= comment.body %> <%# escaped automatically %> <%= raw comment.body %> <%# XSS %> <%= comment.body.html_safe %> <%# XSS: html_safe means "trust me" %> <%== comment.body %> <%# same as raw %> <%# HTML required: Rails' allowlist sanitizer (rails-html-sanitizer / Loofah) %> <%= sanitize comment.body, tags: %w[b i em strong a p], attributes: %w[href] %> <%# user-supplied links: validate the scheme yourself %> <%= link_to "website", user.website if user.website.to_s.match?(%r{\Ahttps?://}) %>
// browser: textContent never parses HTML li.textContent = comment.body; li.innerHTML = comment.body; // XSS // when HTML is required: DOMPurify (browser) or sanitize-html (Node) import DOMPurify from "dompurify"; div.innerHTML = DOMPurify.sanitize(userHtml, { ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p"], ALLOWED_ATTR: ["href"] }); // server templates (EJS): <%= x %> escapes, <%- x %> does NOT
# Jinja2 / Django templates escape {{ x }} automatically. {{ comment.body|safe }} {# XSS #} # when HTML is required: nh3 (fast, Rust-based ammonia binding) import nh3 clean = nh3.clean(user_html, tags={"b", "i", "em", "strong", "a", "p"}, attributes={"a": {"href"}}, url_schemes={"http", "https"}) # escaping by hand outside templates from markupsafe import escape html = f"<li>{escape(name)}</li>"
<%= %> escape ให้ ระวัง raw, html_safe และ <%== ส่วน link_to ไม่บล็อก URL แบบ javascript: และ Rails ป้องกัน JS ที่เราเขียนเองไม่ได้ ลอง grep หา html_safe กับ innerHTMLกลเม็ดการพรางและการ encode
เบราว์เซอร์ใจกว้างมาก: ถอด entity ไม่สนตัวพิมพ์ รัน handler ได้บนแท็กหลายสิบแบบ ตัวกรองสตริงจึงพลาดเสมอ sanitizer จริงจะ parse HTML ก่อน
การ encode output ขึ้นกับบริบท
ไม่มีฟังก์ชัน "escape" ตัวเดียวที่ใช้ได้ทุกที่ การ encode ขึ้นกับว่าข้อมูลไปอยู่ ตรงไหน ลองดู:
| บริบท | เทมเพลต | ผลลัพธ์ | ผลลัพธ์ |
|---|
<p><%= name %></p> <%# HTML body: ERB escapes %> <div title="<%= name %>"> <%# quoted attribute: escaped. Always quote! %> <%= tag.div data: { user: @user.as_json } %> <%# pass data to JS via data-* (JSON, escaped) %> <a href="/search?q=<%= ERB::Util.url_encode(q) %>"> <%# URL parameter: url-encode the value %> <script>const n = "<%= j name %>"</script> <%# works, but a fragile context: avoid %> <div style="color: <%= color %>"> <%# CSS: allowlist values only %>
// browser: pick the API that treats data as data el.textContent = name; // HTML body el.setAttribute("title", name); // attribute (fixed name) el.dataset.user = JSON.stringify(user); // data for scripts url.searchParams.set("q", q); // URL parameter (encodeURIComponent) el.style.setProperty("color", allowedColors.includes(c) ? c : "black"); // CSS via CSSOM + allowlist el.setAttribute("onclick", userCode); // event handlers: never
บริบทที่การ encode ช่วยไม่ได้
บางที่ไม่ปลอดภัยสำหรับข้อมูลผู้ใช้ ไม่ว่าจะ encode อย่างไร: ใน <script>, <style>, event handler หรือ eval() ให้ใส่ข้อมูลใน data attribute แล้วอ่านจากตรงนั้น
DOM-based XSS: source และ sink
XSS บางแบบเกิดในเบราว์เซอร์ล้วนๆ: JS ของเราอ่าน URL แล้วเขียนลงหน้า ส่วนหลัง # ไม่เคยถึงเซิร์ฟเวอร์ การป้องกันฝั่งเซิร์ฟเวอร์จึงมองไม่เห็น
| source (ผู้โจมตีควบคุมได้) | sink อันตราย (parse หรือรันโค้ด) | sink ที่ปลอดภัย (ถือ input เป็นข้อความ) |
|---|---|---|
location.hash, location.search, document.URL, document.referrer, window.name, ข้อมูลจาก postMessage, localStorage, API response ที่มีเนื้อหาจากผู้ใช้ | innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, setTimeout(string), new Function, el.href/src = "javascript:…", jQuery $(html) / .html(), createElement(userTag) | textContent, insertAdjacentText, createTextNode, value, className, setAttribute ที่ชื่อ attribute คงที่และไม่ใช่ event |
el.innerHTML = "Hi " + hash
el.textContent = "Hi " + hash
Trusted Types
เปิดผ่าน CSP แล้วเบราว์เซอร์จะไม่รับสตริงธรรมดาใน innerHTML เข้าได้เฉพาะสิ่งที่ผ่าน policy ที่เราอนุมัติ (เช่น DOMPurify)
# config/initializers/content_security_policy.rb
policy.require_trusted_types_for :script
policy.trusted_types "dompurify", "default"
// Content-Security-Policy: require-trusted-types-for 'script'; trusted-types dompurify const policy = trustedTypes.createPolicy("dompurify", { createHTML: (s) => DOMPurify.sanitize(s), }); el.innerHTML = policy.createHTML(userHtml); // allowed el.innerHTML = userHtml; // TypeError: requires TrustedHTML
รูปแบบที่ควรหลีกเลี่ยง (anti-pattern)
- พึ่ง CSP อย่างเดียว มันคือตาข่ายรองรับ ไม่ใช่ทางแก้
- "ทำความสะอาด" input ทั้งหมดตอนบันทึก ผิดบริบท encode ซ้ำ และพลาด XSS ที่เกิดในเบราว์เซอร์ ให้ escape ตอนแสดงผลแทน
- เชื่อ WAF มีไว้เสริมได้ แต่ไม่ใช่ทางแก้
- แก้ HTML หลัง sanitize ให้ sanitize เป็นขั้นสุดท้าย
ตัวอย่างจากโลกใต้ดิน
สองเหตุการณ์ที่โด่งดัง:
- หนอน Yahoo! Mail (2006): อีเมลที่ส่งตัวเองไปหาทุกคนในรายชื่อเมื่อเปิดอ่าน ตัวกรองลบส่วนอันตรายรอบเดียว ซึ่งกลับประกอบมันขึ้นใหม่
- หนอน MySpace "Samy" (2005): ติดโปรไฟล์กว่าล้านโปรไฟล์ในราว 20 ชั่วโมง ด้วยสคริปต์ที่ซ่อนใน CSS
4.3 CSS injection
การให้ผู้ใช้วาง CSS ดิบมีความเสี่ยง แม้ CSS จะไม่ใช่โค้ด:
- มัน ทำข้อมูลรั่ว ได้ทีละตัวอักษร ผ่าน selector และ URL พื้นหลัง
- มัน ปลอมหน้าเว็บ ได้: ซ่อนของ หรือซ้อนหน้าล็อกอินปลอม
ทางแก้: ให้ตัวเลือก (ตัวเลือกสี รายการฟอนต์) แล้วสร้าง CSS เอง
"วาง CSS ของคุณ"
เลือกธีม
4.4 Markup injection (Markdown, Textile, BBCode)
Markdown renderer ส่วนใหญ่ปล่อย HTML ดิบผ่าน และแปลง [x](javascript:…) เป็นลิงก์ที่ใช้ได้ ให้ render Markdown แล้วค่อย sanitize HTML
HTML ที่ได้
ผลลัพธ์
# gem "commonmarker": raw HTML is omitted by default (unsafe: false) html = Commonmarker.to_html(params[:body]) safe = Rails::HTML5::SafeListSanitizer.new.sanitize(html) # render, then sanitize <%= sanitize markdown_html %> <%# same idea in views %>
import { marked } from "marked";
import DOMPurify from "isomorphic-dompurify";
const html = DOMPurify.sanitize(marked.parse(userMarkdown)); // render, then sanitize
import markdown, nh3
html = nh3.clean(markdown.markdown(user_markdown)) # render, then sanitize
sanitize กับผลลัพธ์ของ Markdown Action Text sanitize ให้แล้ว อย่าเปิดตัวเลือก HTML ดิบใน gem Markdown4.5 Injection ผ่าน Ajax / API response
XSS แอบเข้ามาตรงที่ไม่ได้ใช้เทมเพลต: endpoint ที่สร้าง HTML ด้วยการต่อสตริง หรือ JS ที่เอาข้อมูลจาก API ใส่ innerHTML
# hand-built HTML string: no escaping happens here render html: "<li>#{user.display_name}</li>".html_safe # render a partial (escaped) or return JSON render partial: "users/chip", locals: { user: } # <li><%= user.display_name %></li> render json: { name: user.display_name } # client uses textContent # plain Ruby: helpers.tag.li(user.display_name) or ERB::Util.html_escape(name)
// server builds HTML by hand, client inserts it app.get("/api/user-chip", (req, res) => res.send(`<li>${user.displayName}</li>`)); list.insertAdjacentHTML("beforeend", await (await fetch("/api/user-chip")).text()); // return data, let the client build DOM safely app.get("/api/user", (req, res) => res.json({ name: user.displayName })); const li = document.createElement("li"); li.textContent = (await (await fetch("/api/user")).json()).name; list.append(li);
# returns hand-built HTML: no auto-escaping happens here @app.get("/api/user-chip") def chip(): return f"<li>{user.display_name}</li>" # either escape explicitly… from markupsafe import escape return f"<li>{escape(user.display_name)}</li>" # …or return JSON and build DOM with textContent on the client return jsonify(name=user.display_name)
render json: ปลอดภัย แต่ render html: ที่แทรกสตริงไม่ปลอดภัย4.6 Command-line injection
ถ้าสร้างคำสั่ง shell เป็นสตริงเดียว shell จะอ่าน ;, | และ $( ) ว่า "รันอย่างอื่นต่อ" photo.jpg; rm -rf / คือสองคำสั่ง
ทางแก้: ไม่ผ่าน shell ส่งโปรแกรมกับอาร์กิวเมนต์แยกกัน
exec("convert " + name + " -resize 200 thumb.png")
execFile("convert", ["--", name, "-resize", "200", "thumb.png"])
system("convert #{name} -resize 200 thumb.png") # one string → /bin/sh `convert #{name}` / %x(convert #{name}) / open("| #{cmd}") # shell system("convert", name, "-resize", "200", "thumb.png") # argv, no shell out, status = Open3.capture2e("convert", name, "-resize", "200", "thumb.png") File.open(path) # not Kernel#open(path), which runs "| cmd"
const { execFile } = require("node:child_process");
exec(`convert ${name} -resize 200 thumb.png`); // shell parses name
execFile("convert", [name, "-resize", "200", "thumb.png"], cb); // no shell
// also unsafe: spawn(cmd, { shell: true }), execSync(string)
import subprocess os.system(f"convert {name} -resize 200 thumb.png") # shell subprocess.run(f"convert {name} ...", shell=True) # shell subprocess.run(["convert", name, "-resize", "200", "thumb.png"], check=True) # no shell # if you truly need a shell string, quote each value: shlex.quote(name)
system("convert", name) ปลอดภัย แต่ system("convert #{name}"), backtick และ %x() ไม่ปลอดภัยตัวแอบแฝง: Kernel#open
open("| ls") รัน ls การ "เปิดไฟล์" ด้วยชื่อจากผู้ใช้จึงรันคำสั่งได้ Ruby 3.3 ประกาศเลิกใช้พฤติกรรมนี้แล้ว แต่ยังมีแอปจำนวนมากที่ใช้ Ruby รุ่นเก่า ให้ใช้ File.open
4.7 Header injection
header คือบรรทัดข้อความ ถ้า input ที่มีการขึ้นบรรทัดใหม่ไปอยู่ในนั้น ผู้โจมตีก็เพิ่ม header ของตัวเองได้ หรือแม้แต่ทั้งหน้าปลอม
เฟรมเวิร์กสมัยใหม่ปฏิเสธการขึ้นบรรทัดใหม่ใน header โค้ดที่สร้าง header เองก็ควรทำเช่นกัน
DNS rebinding และการโจมตีผ่าน Host header
แอปมักสร้างลิงก์ในอีเมลจาก header Host ซึ่ง client เป็นคนกำหนด ส่ง Host: evil.test มากับคำขอรีเซ็ต เหยื่อจะได้อีเมลจริงที่ลิงก์ไป evil.test
DNS rebinding เป็นเทคนิคที่ใกล้เคียงกัน โดเมนของผู้โจมตีชี้ไปที่เซิร์ฟเวอร์ของเขาก่อน แล้วสลับไปชี้ที่ที่อยู่ภายในอย่าง 127.0.0.1 เบราว์เซอร์ของเหยื่อจึงไปคุยกับบริการบนเครื่องหรือในเครือข่ายของเหยื่อเอง แต่คำขอเหล่านั้นยังมี hostname ของผู้โจมตีอยู่ใน Host
ทางแก้ทั้งสองแบบ: กำหนด allowlist ของ hostname (ปฏิเสธค่า Host ที่ไม่รู้จัก) และสร้างลิงก์ในอีเมลจาก config
# config/environments/production.rb: HostAuthorization (dev-only by default) config.hosts = ["app.example.com", "www.example.com"] config.host_authorization = { exclude: ->(req) { req.path == "/up" } } # health check # build email links from config, never from request.host config.action_mailer.default_url_options = { host: "app.example.com", protocol: "https" }
const ALLOWED = new Set(["app.example.com", "www.example.com"]);
app.use((req, res, next) => ALLOWED.has(req.hostname) ? next() : res.sendStatus(400));
// build links from config, never from req.headers.host
const resetUrl = new URL(`/reset?token=${token}`, process.env.APP_BASE_URL);
# Django settings.py ALLOWED_HOSTS = ["app.example.com", "www.example.com"] # build links from config, not request.get_host() reset_url = f"{settings.APP_BASE_URL}/reset?token={token}"
config.hosts เปิดแค่ใน development ให้ตั้งใน production.rb พร้อม action_mailer.default_url_options