guest@websec ~ % man 08-user-management
08 · การจัดการผู้ใช้
บัญชีผู้ใช้ดึงดูดการโจมตีง่ายๆ: เดารหัสผ่าน เปลี่ยนอีเมลคนอื่น แก้ ID ใน URL และการรั่วแบบไม่รู้ตัวอีกสองแบบ: log กับ regex
:lockable) แต่ไม่มีตัวไหนเพิ่ม MFA ให้8.1 การ brute force บัญชี
ผู้โจมตีไม่ได้เดาสุ่ม แต่ลองรหัสยอดนิยมและรหัสที่รั่วจากเว็บอื่น (credential stuffing) รหัสส่วนใหญ่แตกภายในไม่กี่พันครั้ง
วิธีป้องกัน:
- ข้อความ error กลางๆ ทั้งหน้าล็อกอินและ "ลืมรหัสผ่าน"
- จำกัดอัตรา ต่อ IP และต่อบัญชี Rails:
rate_limit to: 10, within: 3.minutes - CAPTCHA หลังล้มเหลวหลายครั้ง
- MFA เพื่อให้รหัสถูกอย่างเดียวยังไม่พอ
class SessionsController < ApplicationController
rate_limit to: 10, within: 3.minutes, only: :create,
with: -> { redirect_to new_session_url, alert: "Try again later." }
end
# config/initializers/rack_attack.rb: per-account limit across all IPs
Rack::Attack.throttle("logins/email", limit: 20, period: 1.hour) do |req|
req.params["email_address"].to_s.downcase.presence if req.path == "/session" && req.post?
end
const rateLimit = require("express-rate-limit");
const loginLimiter = rateLimit({
windowMs: 3 * 60 * 1000, limit: 10, // 10 per 3 minutes per IP
standardHeaders: "draft-7", legacyHeaders: false,
message: "Too many attempts. Try again in a few minutes.",
});
app.post("/login", loginLimiter, loginHandler);
// add a per-account counter (e.g. in Redis keyed by email) to stop botnets
// that rotate IPs, and require a CAPTCHA once it passes a threshold
# Flask: pip install Flask-Limiter from flask_limiter import Limiter from flask_limiter.util import get_remote_address limiter = Limiter(get_remote_address, app=app, storage_uri="redis://localhost") @app.post("/login") @limiter.limit("10 per 3 minutes") def login(): ... # Django: django-axes locks out by username + IP after N failures # INSTALLED_APPS += ["axes"]; AXES_FAILURE_LIMIT = 5
rate_limit ต้องใช้ cache ที่แชร์กันใน production ไม่อย่างนั้นแต่ละเซิร์ฟเวอร์นับแยกกัน ถ้าต้องการจำกัดต่อบัญชี เพิ่ม rack-attack8.2 การยึดบัญชี
มีคนได้ session ไปชั่วครู่ (โน้ตบุ๊กไม่ได้ล็อก, XSS) เขาจะพยายามยึดถาวรด้วยการเปลี่ยนรหัสผ่านหรืออีเมล
รหัสผ่าน
ขอ รหัสผ่านปัจจุบัน ก่อนตั้งรหัสใหม่
อีเมล
อีเมลก็เช่นกัน เพราะอีเมลใหม่ = คุมการรีเซ็ตรหัสได้ และแจ้งไปที่อีเมลเดิมด้วย
อื่นๆ
เบอร์โทร อุปกรณ์ MFA และกฎส่งต่ออีเมล ก็ต้องดูแลแบบเดียวกัน
user.authenticate(params[:current_password])8.3 CAPTCHA
มีสองแบบ:
- แบบที่เห็น (reCAPTCHA, Turnstile): ให้คนพิสูจน์ตัว ได้ผล แต่น่ารำคาญ
- แบบที่มองไม่เห็น: ฟิลด์ honeypot ที่ซ่อนไว้ คนมองไม่เห็น แต่บอทโง่ๆ จะกรอก
ทั้งสองแบบใช้แทน rate limit ไม่ได้
สิ่งที่คนเห็น
HTML ของฟอร์มจริง
<input name="name">
<input name="comment">
<!-- hidden from humans -->
<div style="position:absolute;left:-9999px"
aria-hidden="true">
<input name="website" tabindex="-1"
autocomplete="off">
</div>
<input type="hidden" name="ts" value="…">8.4 การบันทึก log
เฟรมเวิร์ก log พารามิเตอร์ของ request ทำให้รหัสผ่านและเลขบัตรไปอยู่ในไฟล์ log แบบอ่านออก ซึ่งถูกแชร์และเก็บไว้หลายเดือน
ปิดบังพารามิเตอร์ที่ละเอียดอ่อนก่อนถูกบันทึก
# config/initializers/filter_parameter_logging.rb (generated) Rails.application.config.filter_parameters += [ :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc, :card_number, :national_id # add your own ]
// pino (common Express logger) supports redaction paths
const logger = require("pino")({
redact: { paths: ["req.body.password", "req.body.card_number",
"req.headers.authorization", "req.headers.cookie", "*.token"],
censor: "[FILTERED]" },
});
# Django: mark views so error reports hide values from django.views.decorators.debug import sensitive_post_parameters @sensitive_post_parameters("password", "card_number") def signup(request): ... # Generic Python: a logging.Filter that scrubs known keys class Redact(logging.Filter): KEYS = ("passw", "secret", "token", "card") def filter(self, record): if isinstance(record.args, dict): record.args = {k: "[FILTERED]" if any(s in k.lower() for s in self.KEYS) else v for k, v in record.args.items()} return True
filter_parameters ปิดบังรหัสผ่าน token คีย์ และอื่นๆ ให้แล้ว ให้เพิ่มฟิลด์ของเราเอง เช่น เลขบัตร8.5 Regular expression
anchor "ต้น" กับ "ท้าย" มีความหมายต่างกันในแต่ละภาษา:
| ภาษา | ^ / $ หมายถึง… | anchor ทั้งสตริง |
|---|---|---|
| Ruby | ต้น / ท้ายของ บรรทัดใดก็ได้ เสมอ | \A … \z |
| JavaScript | ต้น / ท้ายของสตริง เว้นแต่ตั้ง flag m | ^ … $ โดยไม่มี m |
| Python | ต้น / ท้ายของสตริง แต่ $ ยังตรงก่อน \n ตัวท้ายด้วย และเมื่อใช้ re.M จะเป็นบรรทัดใดก็ได้ | \A … \Z หรือ re.fullmatch |
| PHP (PCRE) | เหมือน Python: $ ยอมให้มีขึ้นบรรทัดใหม่ตัวท้าย เว้นแต่ใช้ flag D | \A … \z |
| Java | matches() ตรวจทั้งสตริง | matches() หรือ \A…\z |
บั๊กคลาสสิก: การเช็ก URL ผ่านเพราะ บรรทัดที่สอง ดูปกติ แต่บรรทัดแรกเป็น javascript: ให้ anchor ทั้งสตริง และทดสอบด้วยการขึ้นบรรทัดใหม่
| pattern | ทำงานเหมือน | ผลลัพธ์ |
|---|
^/$ เว้นแต่ใส่ multiline: true แต่ regex ที่อื่นไม่มีการเช็กแบบนี้8.6 การยกระดับสิทธิ์
หนึ่งในบั๊กที่พบบ่อยที่สุดบนเว็บ: เปลี่ยน /projects/1042 เป็น 1043 แล้วเห็นโปรเจกต์ของคนอื่น เรียกว่า IDOR (Insecure Direct Object Reference)
ทางแก้: ค้นผ่านผู้ใช้ปัจจุบันเสมอ current_user.projects.find(id) ไม่ใช่ Project.find(id)
และอย่าลืม: hidden field กับการเช็กฝั่งหน้าเว็บไม่ใช่ความปลอดภัย ใครก็แก้สิ่งที่เบราว์เซอร์ส่งได้
Project.find(id)
current_user.projects.find(id)
# VULNERABLE: any logged-in user can read any project @project = Project.find(params[:id]) # SAFE: scope through the current user; others get 404 (RecordNotFound) @project = Current.user.projects.find(params[:id]) # shared resources: a policy object (Pundit) @project = Project.find(params[:id]) authorize @project # ProjectPolicy#show? decides
// VULNERABLE: any logged-in user can read any project const p = await db.query("SELECT * FROM projects WHERE id = $1", [req.params.id]); // SAFE: owner is part of the lookup; others get a 404 const p = await db.query( "SELECT * FROM projects WHERE id = $1 AND owner_id = $2", [req.params.id, req.user.id]); if (!p.rows.length) return res.sendStatus(404);
# VULNERABLE (Django) project = Project.objects.get(pk=pk) # SAFE: scope through the current user project = get_object_or_404(Project, pk=pk, owner=request.user) # or: request.user.projects.get(pk=pk) via the related name
current_user หรือใช้ Pundit ดูเพิ่มที่หัวข้อ 09