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)
เบราว์เซอร์ของเหยื่อ
แพ็กเก็ตที่ผู้โจมตีดักได้
วิธีป้องกัน ใช้ได้กับทุก 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 ได้ทุกคน
ใครก็ถอดออกมาอ่านได้ แค่แก้ไม่ได้
ค่าเริ่มต้นของ Rails: อ่านไม่ได้และแก้ไม่ได้ แต่ยัง replay ได้ และเพิกถอนไม่ได้ถ้าไม่หมุนคีย์
คุกกี้เป็นแค่คีย์สุ่มสำหรับค้นหา ข้อมูลอยู่บนเซิร์ฟเวอร์ และการลบแถวทำให้ผู้ใช้ออกจากระบบทันที
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 เครดิตคืน ทางแก้: เก็บยอดในฐานข้อมูล
ยอดที่เซิร์ฟเวอร์เชื่อ
คุกกี้ในเบราว์เซอร์
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
reset_session6.7 การหมดอายุของ session
session ที่ไม่มีวันหมดอายุ ให้เวลาผู้โจมตีไม่จำกัด ให้หมดอายุ ที่เซิร์ฟเวอร์ สองแบบ:
- Idle: ไม่มีการใช้งาน N นาที ก็ออกจากระบบ
- Absolute: ครบ N ชั่วโมง ออกจากระบบเสมอ
expire_after: และอายุสูงสุดจาก Session#created_at