guest@websec: ~/websec/08-user-management/th — zsh — 100×40 เขียนโดย SaKKo ENTH โค้ด:

guest@websec ~ % man 08-user-management

08 · การจัดการผู้ใช้

บัญชีผู้ใช้ดึงดูดการโจมตีง่ายๆ: เดารหัสผ่าน เปลี่ยนอีเมลคนอื่น แก้ ID ใน URL และการรั่วแบบไม่รู้ตัวอีกสองแบบ: log กับ regex

generator และ Devise ดูแลพื้นฐานให้ Devise ล็อกบัญชีได้ด้วย (:lockable) แต่ไม่มีตัวไหนเพิ่ม MFA ให้

8.1 การ brute force บัญชี

ผู้โจมตีไม่ได้เดาสุ่ม แต่ลองรหัสยอดนิยมและรหัสที่รั่วจากเว็บอื่น (credential stuffing) รหัสส่วนใหญ่แตกภายในไม่กี่พันครั้ง

วิธีป้องกัน:

  • ข้อความ error กลางๆ ทั้งหน้าล็อกอินและ "ลืมรหัสผ่าน"
  • จำกัดอัตรา ต่อ IP และต่อบัญชี Rails: rate_limit to: 10, within: 3.minutes
  • CAPTCHA หลังล้มเหลวหลายครั้ง
  • MFA เพื่อให้รหัสถูกอย่างเดียวยังไม่พอ
sim: dictionary attack ปะทะ rate limitingรหัสผ่านจริงอยู่ลำดับที่ 8,114 ในรายการของผู้โจมตี
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-attack

8.2 การยึดบัญชี

มีคนได้ session ไปชั่วครู่ (โน้ตบุ๊กไม่ได้ล็อก, XSS) เขาจะพยายามยึดถาวรด้วยการเปลี่ยนรหัสผ่านหรืออีเมล

รหัสผ่าน

ขอ รหัสผ่านปัจจุบัน ก่อนตั้งรหัสใหม่

อีเมล

อีเมลก็เช่นกัน เพราะอีเมลใหม่ = คุมการรีเซ็ตรหัสได้ และแจ้งไปที่อีเมลเดิมด้วย

อื่นๆ

เบอร์โทร อุปกรณ์ MFA และกฎส่งต่ออีเมล ก็ต้องดูแลแบบเดียวกัน

sim: ผู้โจมตีที่มี session ที่ขโมยมาลองยึดบัญชี
Devise ขอรหัสปัจจุบันให้แล้ว ถ้าเป็นโค้ดตั้งค่าที่เราเขียนเอง ให้เช็ก user.authenticate(params[:current_password])

8.3 CAPTCHA

มีสองแบบ:

  • แบบที่เห็น (reCAPTCHA, Turnstile): ให้คนพิสูจน์ตัว ได้ผล แต่น่ารำคาญ
  • แบบที่มองไม่เห็น: ฟิลด์ honeypot ที่ซ่อนไว้ คนมองไม่เห็น แต่บอทโง่ๆ จะกรอก

ทั้งสองแบบใช้แทน rate limit ไม่ได้

sim: ฟิลด์ honeypotส่งแบบคน แล้วส่งแบบบอท

สิ่งที่คนเห็น

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 แบบอ่านออก ซึ่งถูกแชร์และเก็บไว้หลายเดือน

ปิดบังพารามิเตอร์ที่ละเอียดอ่อนก่อนถูกบันทึก

sim: ตัวกรอง 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
Javamatches() ตรวจทั้งสตริงmatches() หรือ \A…\z

บั๊กคลาสสิก: การเช็ก URL ผ่านเพราะ บรรทัดที่สอง ดูปกติ แต่บรรทัดแรกเป็น javascript: ให้ anchor ทั้งสตริง และทดสอบด้วยการขึ้นบรรทัดใหม่

sim: anchor กับการขึ้นบรรทัดใหม่regex engine ของ JavaScript แบบสด
patternทำงานเหมือนผลลัพธ์
format validator ของ Rails ไม่ยอมรับ ^/$ เว้นแต่ใส่ multiline: true แต่ regex ที่อื่นไม่มีการเช็กแบบนี้

8.6 การยกระดับสิทธิ์

หนึ่งในบั๊กที่พบบ่อยที่สุดบนเว็บ: เปลี่ยน /projects/1042 เป็น 1043 แล้วเห็นโปรเจกต์ของคนอื่น เรียกว่า IDOR (Insecure Direct Object Reference)

ทางแก้: ค้นผ่านผู้ใช้ปัจจุบันเสมอ current_user.projects.find(id) ไม่ใช่ Project.find(id)

และอย่าลืม: hidden field กับการเช็กฝั่งหน้าเว็บไม่ใช่ความปลอดภัย ใครก็แก้สิ่งที่เบราว์เซอร์ส่งได้

sim: แก้ IDคุณคือผู้ใช้ #7 (dana)
GET /projects/

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
ไม่มี authorization ในตัว ให้ค้นผ่าน current_user หรือใช้ Pundit ดูเพิ่มที่หัวข้อ 09