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

guest@websec ~ % man 06-sessions

06 · Session

HTTP จำใครไม่ได้ระหว่างแต่ละ request session คือวิธีที่แอปจำคุณ ใครถือคุกกี้ session ก็ คือ คุณ ในสายตาของเซิร์ฟเวอร์

6.1 session คืออะไร

หลังล็อกอิน เบราว์เซอร์ได้คุกกี้มาแล้วส่งกลับทุกครั้ง มีสองแบบที่นิยม:

  • ฝั่งเซิร์ฟเวอร์: คุกกี้เป็นแค่ ID สุ่ม ข้อมูลอยู่บนเซิร์ฟเวอร์
  • ฝั่ง client: ข้อมูลอยู่ในคุกกี้เลย ลงนามและเข้ารหัสไว้ ค่าเริ่มต้นของ Rails เป็นแบบนี้

ไม่ว่าแบบไหน คุกกี้คือ bearer credential: ใครยื่นมันก็เข้าระบบได้

6.2 การขโมย session (hijacking)

ขโมยคุกกี้ได้ ก็เป็นผู้ใช้คนนั้นได้ ไม่ต้องรู้รหัส วิธีที่พบบ่อย:

  • ดักฟัง (sniffing) บนเครือข่ายที่ไม่เข้ารหัส (Wi-Fi ร้านกาแฟ, เราเตอร์ที่ถูกเจาะ) เมื่อเว็บใช้ HTTP ธรรมดาที่ใดที่หนึ่ง
  • คอมพิวเตอร์ที่ใช้ร่วมกัน ซึ่งผู้ใช้คนก่อนไม่ได้ออกจากระบบ
  • XSS: JavaScript ที่ถูกแทรกเข้ามาอ่าน document.cookie แล้วส่งให้ผู้โจมตี (หัวข้อ 04)
  • session fixation: ผู้โจมตีกำหนด ID ไว้ ก่อน ที่เหยื่อจะล็อกอิน (ดู 6.6)
sim: ดักฟัง Wi-Fi ร้านกาแฟเปิด/ปิดการป้องกัน แล้วส่ง request

เบราว์เซอร์ของเหยื่อ

แพ็กเก็ตที่ผู้โจมตีดักได้

วิธีป้องกัน ใช้ได้กับทุก stack:

  • ใช้ HTTPS ทุกที่ ใน Rails: config.force_ssl = true
  • ตั้ง flag ของคุกกี้: Secure, HttpOnly, SameSite=Lax
  • มีปุ่ม ออกจากระบบ ที่เห็นชัด และทำลาย session ที่ฝั่งเซิร์ฟเวอร์
  • ป้องกัน XSS เพราะ XSS ข้ามการป้องกันข้างต้นทั้งหมดได้
# config/environments/production.rb
config.assume_ssl = true      # behind a TLS-terminating load balancer
config.force_ssl  = true      # HTTPS redirect + HSTS + Secure cookies

# config/initializers/session_store.rb
Rails.application.config.session_store :cookie_store,
  key: "__Host-myapp_session", secure: true, same_site: :lax, expire_after: 30.minutes
// Express + express-session
app.set("trust proxy", 1);                 // behind a load balancer
app.use(helmet.hsts({ maxAge: 31536000 }));
app.use(session({
  secret: process.env.SESSION_SECRET,
  cookie: { secure: true, httpOnly: true, sameSite: "lax" },
  resave: false, saveUninitialized: false,
}));
app.use((req, res, next) =>              // force HTTPS
  req.secure ? next() : res.redirect(301, "https://" + req.headers.host + req.url));
# Django settings.py
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
CSRF_COOKIE_SECURE = True

# Flask equivalent
app.config.update(SESSION_COOKIE_SECURE=True,
                  SESSION_COOKIE_HTTPONLY=True,
                  SESSION_COOKIE_SAMESITE="Lax")
force_ssl จัดการ HTTPS, HSTS และคุกกี้ Secure ให้ (ตรวจว่าเปิดใน production.rb) คุกกี้ session เป็น HttpOnly และ SameSite=Lax อยู่แล้ว ปุ่มออกจากระบบกับ XSS เป็นหน้าที่เรา

6.3 ข้อมูล session เก็บไว้ที่ไหน

ค่าเริ่มต้นของ Rails เก็บ session ในคุกกี้ที่เข้ารหัส สะดวก แต่มีข้อควรรู้:

  • ผู้ใช้มีสำเนาไว้ อ่านหรือแก้ไม่ได้ แต่ส่งของเก่ากลับมาได้
  • เพิกถอนไม่ได้ ใช้ได้จนกว่าจะหมดอายุหรือเปลี่ยนคีย์
  • secret ตัวเดียวคุมทั้งหมด ถ้า secret_key_base รั่ว ใครก็ปลอม session ได้ทุกคน
sim: ข้างในคุกกี้ sessionเลือกวิธีเก็บ

ใครก็ถอดออกมาอ่านได้ แค่แก้ไม่ได้

ค่าเริ่มต้นของ Rails: อ่านไม่ได้และแก้ไม่ได้ แต่ยัง replay ได้ และเพิกถอนไม่ได้ถ้าไม่หมุนคีย์

คุกกี้เป็นแค่คีย์สุ่มสำหรับค้นหา ข้อมูลอยู่บนเซิร์ฟเวอร์ และการลบแถวทำให้ผู้ใช้ออกจากระบบทันที

หลักง่ายๆ: คุกกี้เก็บแค่ ID ของมีค่าเก็บในฐานข้อมูล
CookieStore ค่าเริ่มต้นเข้ารหัสแล้ว แต่ replay ได้และเพิกถอนไม่ได้ generator ของ Rails 8 ดีกว่า: เก็บ session เป็นแถวในฐานข้อมูล จึงสั่งให้ออกจากระบบได้

6.4 การหมุนเวียนคีย์และอัลกอริทึม

สักวันต้องเปลี่ยน secret ถ้าเปลี่ยนทันที ทุกคนหลุดจากระบบ ให้ หมุนเวียน แทน: ลงนามด้วยคีย์ใหม่ แต่ยังรับคีย์เก่าไปสักพัก

cookies_rotations ทำแบบนี้พอดี: รับคีย์เก่า แล้วอัปเกรดคุกกี้เมื่อผู้ใช้กลับมา
# config/initializers/cookie_rotator.rb (Rails guide example)
# Move signed cookies from SHA1 to SHA256 without logging anyone out:
# new cookies use SHA256, old SHA1 cookies still work and get upgraded
Rails.application.config.action_dispatch.signed_cookie_digest = "SHA256"
Rails.application.config.action_dispatch.cookies_rotations.tap do |cookies|
  cookies.rotate :signed, digest: "SHA1"
end
# the same rotate call can also accept cookies made with an old secret
// first secret signs new cookies; the rest are only accepted for verification
app.use(session({ secret: [process.env.SESSION_SECRET_2025, process.env.SESSION_SECRET_2024] }));
# Django 4.1+: new key signs, fallbacks still verify
SECRET_KEY = os.environ["SECRET_KEY_2025"]
SECRET_KEY_FALLBACKS = [os.environ["SECRET_KEY_2024"]]

6.5 การโจมตีแบบ replay กับ session ฝั่ง client

สมมติคุกกี้เก็บ 100 เครดิต ผู้ใช้เซฟสำเนาไว้ ใช้จนหมด แล้วเอาคุกกี้เก่ากลับมา ลายเซ็นยังถูกต้อง... ได้ 100 เครดิตคืน ทางแก้: เก็บยอดในฐานข้อมูล

sim: กลเม็ดเครดิตไม่จำกัดลองกับวิธีเก็บแต่ละแบบ
session store:

ยอดที่เซิร์ฟเวอร์เชื่อ

100

คุกกี้ในเบราว์เซอร์

ไม่มีอะไรกันการ replay คุกกี้ได้ เงิน เครดิต และสิทธิ์ ต้องอยู่ในฐานข้อมูล ไม่ใช่ใน session[]

6.6 session fixation

คราวนี้ผู้โจมตีฝัง session ID ไว้ ก่อน คุณล็อกอิน แล้วใช้ต่อหลังจากนั้น

วิธีป้องกัน

ทางแก้: ออก session ID ใหม่ตอนล็อกอิน (และตอนออก) ใน Rails คือ reset_session

def create
  if user = User.authenticate_by(params.permit(:email_address, :password))
    cart = session[:cart]                 # keep what you need
    reset_session                         # new session id, old one discarded
    session[:user_id] = user.id
    session[:cart] = cart
    redirect_to root_path
  end
end
# Devise/Warden and the Rails 8 generator already do this on sign-in
app.post("/login", async (req, res, next) => {
  const user = await verifyCredentials(req.body);
  if (!user) return res.status(401).send("Invalid email or password.");
  const cart = req.session.cart;              // keep what you need
  req.session.regenerate(err => {             // new ID, old one destroyed
    if (err) return next(err);
    req.session.userId = user.id;
    req.session.cart = cart;
    res.redirect("/");
  });
});
# Django: auth.login() rotates the session key for you
from django.contrib.auth import login
login(request, user)            # calls request.session.cycle_key()

# Flask: there is no session ID with the default cookie store, but with
# a server-side store (Flask-Session) clear and rebuild on login:
cart = session.get("cart")
session.clear()
session["user_id"] = user.id
session["cart"] = cart
Devise และ generator ของ Rails 8 ทำให้แล้ว ถ้าเขียนล็อกอินเอง ต้องเรียก reset_session

6.7 การหมดอายุของ session

session ที่ไม่มีวันหมดอายุ ให้เวลาผู้โจมตีไม่จำกัด ให้หมดอายุ ที่เซิร์ฟเวอร์ สองแบบ:

  • Idle: ไม่มีการใช้งาน N นาที ก็ออกจากระบบ
  • Absolute: ครบ N ชั่วโมง ออกจากระบบเสมอ
session ของ Rails ไม่หมดอายุฝั่งเซิร์ฟเวอร์โดยค่าเริ่มต้น ให้เพิ่ม expire_after: และอายุสูงสุดจาก Session#created_at
sim: idle timeout เทียบกับ absolute timeoutidle 30 นาที · absolute 8 ชม.