guest@websec: ~/websec/04-xss-and-other-injection/th — zsh — 100×40 เขียนโดย SaKKo ENTH โค้ด:

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> แล้วคุณจะได้ตัวใหม่
sim: payload ที่ซ่อมตัวเองได้blocklist ที่ลบ <script>

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 และแม้ตั้งแล้ว สคริปต์ก็ยังทำงานในนามผู้ใช้ได้

ปลอมหน้าเว็บ: วางกล่องล็อกอินปลอมบนโดเมนจริงของเรา

sim: สนามทดลอง XSSเบราว์เซอร์จะทำอะไรกับความคิดเห็นนี้

ลองพิมพ์ความคิดเห็นในฐานะผู้โจมตี เราจะแสดงว่าเบราว์เซอร์จะทำอะไรกับมัน โดยไม่รันจริง

payloads:

ดิบ: element.innerHTML = comment

escape: เทมเพลต auto-escape

sanitize: allowlist b, i, a[href=https]

ผลการแสดงผล

วิธีป้องกัน

  1. escape ตอนแสดงผล เทมเพลตทำให้อยู่แล้ว บั๊กมาจากช่องทางเลี่ยง: raw, html_safe, innerHTML
  2. ให้ตรงบริบท อย่าใส่ข้อมูลใน <script> หรือ onclick ให้ใช้ attribute data-*
  3. ต้องรับ HTML จากผู้ใช้? sanitize ด้วยไลบรารี (sanitize ของ Rails, DOMPurify) ห้ามใช้ regex
  4. ตรวจ URL ว่าขึ้นต้นด้วย http(s)
  5. เพิ่ม 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 ขึ้นกับว่าข้อมูลไปอยู่ ตรงไหน ลองดู:

sim: ค่าเดียว ห้าบริบทเทมเพลตสร้างอะไร และปลอดภัยหรือไม่
try:
บริบทเทมเพลตผลลัพธ์ผลลัพธ์
<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
sim: source → sinkโค้ดหน้าเว็บที่อ่าน location.hash

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 เอง

sim: ธีมโปรไฟล์ผู้ใช้CSS ดิบเทียบกับ CSS ที่สร้างให้

"วาง CSS ของคุณ"

เลือกธีม

4.4 Markup injection (Markdown, Textile, BBCode)

Markdown renderer ส่วนใหญ่ปล่อย HTML ดิบผ่าน และแปลง [x](javascript:…) เป็นลิงก์ที่ใช้ได้ ให้ render Markdown แล้วค่อย sanitize HTML

sim: Markdown rendererrenderer ตัวจิ๋ว: **ตัวหนา**, [ลิงก์](url), 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 Markdown

4.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 ส่งโปรแกรมกับอาร์กิวเมนต์แยกกัน

sim: ตัวสร้างภาพย่อshell จะแยกคำสั่งอย่างไร
try:

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

header คือบรรทัดข้อความ ถ้า input ที่มีการขึ้นบรรทัดใหม่ไปอยู่ในนั้น ผู้โจมตีก็เพิ่ม header ของตัวเองได้ หรือแม้แต่ทั้งหน้าปลอม

sim: HTTP response ดิบinput ของผู้ใช้ไปอยู่ใน header Location

เฟรมเวิร์กสมัยใหม่ปฏิเสธการขึ้นบรรทัดใหม่ใน header โค้ดที่สร้าง header เองก็ควรทำเช่นกัน

Rails และ app server สมัยใหม่อย่าง Puma ปฏิเสธการขึ้นบรรทัดใหม่ในค่า 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