비밀번호 재설정 토큰이 특정 사용자 계정에 바인딩되지 않는 취약점을 분석하였다.
토큰을 이용한 비밀번호 변경 과정에서 사용자 ID 검증이 이루어지지 않을 경우 다른 계정의 비밀번호를 변경할 수 있는 공격 시나리오를 구현하였다.
본 시나리오는 일부 레거시 웹 서비스에서 실제로 관찰된 구현 패턴을 기반으로 한다.
개발자는 비밀번호 변경 시 “어떤 사용자의 비밀번호가 변경되었는지”를 명확히 표시하고자 토큰 검증과 별도로 사용자 ID를 입력받는 UI를 추가하였다.
그러나 서버 단에서 토큰과 사용자 ID의 관계를 검증하지 않아 토큰이 특정 계정에 바인딩되지 않는 취약점이 발생하였다.
취약한 사이트 구현 시나리오
- 회원제로 운영되는 사이트가 있다.
- 회원 정보로는 ID, 패스워드, 이메일이 있다.
- 게시판에서 비밀번호를 이메일로 넘겨받은 토큰 기반으로 변경할 수 있다.
- 이메일로 받은 링크를 클릭하면 비밀번호를 변경하라는 창이 뜬다. 입력한 아이디와 비밀번호로 값이 변경된다.

- 두 명의 사용자가 있다. 둘의 비밀번호는 동일하다
- 1c2649394:passwd
- sample:passwd
- 누구의 비밀번호가 변경 되었는지를 표시하기 위해 비밀번호 변경 시 아이디 또한 입력하게 하였다.
구현
비밀번호 재설정 페이지
forgot_password.php
<form method="POST" action="forgot_password_process.php">
<input type="email" name="email" placeholder="이메일을 입력하세요" required>
<button type="submit">비밀번호 재설정</button>
</form>
비밀번호 재설정 메일 발송
forgot_password_process.php
<?php
require './vendor/autoload.php';
use PHPMailer\\PHPMailer\\PHPMailer;
include "./include/db_connect.php";
$email = $_POST['email'];
// 이메일 존재 여부 노출 방지
$sql = "SELECT id FROM _mem WHERE email=?";
$stmt = $con->prepare($sql);
$stmt->bind_param("s", $email);
$stmt->execute();
$result = $stmt->get_result();
if (!$row = $result->fetch_assoc()) {
echo "메일을 확인하세요.";
exit;
}
$user_id = $row['id'];
// 토큰 생성
$token = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $token);
$expires = date("Y-m-d H:i:s", time() + 1800); // 3분
// 저장
$sql = "INSERT INTO password_resets (user_id, token_hash, expires_at, used)
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 MINUTE), 0)";
$stmt = $con->prepare($sql);
$stmt->bind_param("ss", $user_id, $tokenHash);
$stmt->execute();
// 메일 전송
$link = "<http://localhost/Auth_vuln/token_vuln/reset_password.php?token=$token>";
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = 'smtp.gmail.com';
$mail->SMTPAuth = true;
$mail->Username = '1c2649394@gmail.com';
$mail->Password = 'zzkb xwbk zeae sinj';
$mail->SMTPSecure = 'tls';
$mail->Port = 587;
$mail->setFrom('1c2649394@gmail.com', '관리자');
$mail->addAddress($email);
$mail->CharSet = 'UTF-8';
$mail->Subject = '비밀번호 재설정';
$mail->Body = "아래 링크를 클릭하세요:\\n$link";
$mail->send();
echo "
<script>
alert('메일을 확인하세요.');
location.href = './index.php';
</script>";
- 사용자로부터 이메일($email)을 입력받으면, DB의 password_resets 테이블에 그 이메일을 소유한 아이디($user_id), 해시된 토큰($tokenHash), 만료일(expires_at), 토큰의 사용 여부(used)데이터가 한 행으로 INSERT 된다.
- PHPMailer 클래스를 이용해 메일을 발송한다.
- 메일 함을 확인해보면, 토큰을 포함한 링크가 와있다.

- 그 링크에 접속하면, reset_password.php 페이지에서 아이디와 비밀번호를 입력한다.
<?php if (!isset($_GET['token'])) { die("잘못된 접근"); } $token = $_GET['token']; ?> <form method="POST" action="./reset_password_process.php"> <input type="hidden" name="token" value="<?=htmlspecialchars($token)?>"> <input type="text" name="user_id" placeholder="아이디" required> <!--취약한 구현 : 공격자가 조작 가능--> <input type="password" name="password" placeholder="변경할 비밀번호" required> <button type="submit">변경</button> </form> - reset_password.php
- 토큰에 해당하는 아이디의 비밀번호를 변경한다.
<?php include "./include/db_connect.php"; $token = $_POST['token']; $tokenHash = hash('sha256', $token); $password = password_hash($_POST['password'], PASSWORD_DEFAULT); $sql = "SELECT * FROM password_resets WHERE token_hash = ? AND used = 0"; $stmt = $con->prepare($sql); $stmt->bind_param("s", $tokenHash); $stmt->execute(); $result = $stmt->get_result(); $row = $result->fetch_assoc(); if (!$row) { die("토큰이 유효하지 않습니다"); } $num = $row['num']; // $user_id = $row["user_id"]; // 올바른 구현: 공격자가 조작 불가 $user_id = $_POST["user_id"]; // 취약한 구현 : 공격자가 조작 가능 // 비밀번호 변경 $sql = "UPDATE _mem SET pass=? WHERE id=?"; $stmt = $con->prepare($sql); $stmt->bind_param("ss", $password, $user_id); $stmt->execute(); // 토큰 1회성 처리 $sql = "UPDATE password_resets SET used=1 WHERE num=?"; $stmt = $con->prepare($sql); $stmt->bind_param("i", $num); $stmt->execute(); echo "$user_id 비밀번호 변경 완료"; - reset_password_process.php
취약점 : 이메일 링크로 받은 페이지에서 넘겨받은 아이디를 검증하지 않고 그냥 믿어버린다.
reset_password_process.php
(...)
$num = $row['num'];
$user_id = $_POST["user_id"]; // 취약한 구현 : 공격자가 조작 가능
// 비밀번호 변경
$sql = "UPDATE _mem SET pass=? WHERE id=?";
$stmt = $con->prepare($sql);
$stmt->bind_param("ss", $password, $user_id);
$stmt->execute();
(...)
echo "$user_id 비밀번호 변경 완료";
- reset_password.php에서 어떤 아이디를 입력하든, 그 아이디에 대한 검증을 하지 않는다.
- 이 아이디가 정말로 토큰에 해당하는 아이디인지 검증하지 않는다.
$user_id = $_POST["user_id"]; - 즉, 토큰이 아이디에 강력하게 바인딩이 되어있지 않다.
공격 시나리오
- 1c2649394로 로그인 한다.
- [비밀번호 수정] 버튼을 누르고 비밀번호 수정 토큰을 받을 이메일을 입력한다.


- 메일함에 있는 재설정 메일을 클릭해 토큰이 포함된 아래 링크에 접속한다.

- 아이디로 1c2649394가 아닌 sample을 입력한다.
- sample:12345


- sample:12345로 다시 로그인해보자.


sample 계정 로그인 성공
해결 방안
비밀번호 변경 후 브라우저에 표시할 사용자 아이디를 password_resets 테이블에서 토큰에 해당하는 id를 가져와 $user_id변수에 넣는다.
$sql = "SELECT * FROM password_resets WHERE token_hash = ? AND used = 0";
(...)
$num = $row['num'];
**$user_id = $row["user_id"]; // 올바른 구현: 공격자가 조작 불가**
// 비밀번호 변경
$sql = "UPDATE _mem SET pass=? WHERE id=?";
$stmt = $con->prepare($sql);
$stmt->bind_param("ss", $password, $user_id);
$stmt->execute();
// 토큰 1회성 처리
$sql = "UPDATE password_resets SET used=1 WHERE num=?";
$stmt = $con->prepare($sql);
$stmt->bind_param("i", $num);
$stmt->execute();
echo "$user_id 비밀번호 변경 완료";
그리고 이메일 링크로 들어가는 reset_password.php에서는 아이디를 따로이 입력받지 않는다.
<?php
if (!isset($_GET['token'])) {
die("잘못된 접근");
}
$token = $_GET['token'];
?>
<form method="POST" action="./reset_password_process.php">
<input type="hidden" name="token" value="<?=htmlspecialchars($token)?>">
<input type="password" name="password" placeholder="변경할 비밀번호" required>
<button type="submit">변경</button>
</form>
- 이렇게 하면 비밀번호 변경 토큰이 아이디에 확실히 바인딩 되기 때문에 공격자가 토큰에 연결될 아이디를 조작할 수 없다.
'Normaltic 취업반 > 인증 및 인가 취약점' 카테고리의 다른 글
| CTF 풀이 - Authentication Bypass (0) | 2026.03.20 |
|---|---|
| 권한 상승 취약점 구현 시나리오 (0) | 2026.03.20 |
| JWT 서명 검증 우회를 통한 관리자 권한 탈취 분석 (0) | 2026.03.20 |
| 인증/인가 취약점 (0) | 2026.03.20 |
| CTF 풀이 - Authentication Bypass (0) | 2025.11.14 |