Post

2차 SQL injection 시큐어 코딩 및 실습

입력값 받는 모든 곳에 선처리 질의문을 적용하기 전에, 2차 SQLi가 어떤 식으로 작용하는지 확인만 해보려고 함.

SQLi에 취약한 페이지

1. 회원가입 기능 페이지 (register_process.php)

입력값 이스케이프만 하고 있음!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?php
// error_reporting(E_ALL);
// ini_set('display_errors', 1);
// mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
$dbhost = 'localhost';
$dbuser = 'test22';
$dbpass = 'pass22';
$dbname = 'logintest';
$conn = mysqli_connect($dbhost, $dbuser, $dbpass, $dbname);
if (!$conn) {
    die('Could not connect: ' . mysqli_error());
}

// POST 데이터 가져오기
$username = mysqli_real_escape_string($conn, $_POST['username']);
$password = mysqli_real_escape_string($conn, $_POST['password']);
$password2 = mysqli_real_escape_string($conn, $_POST['password2']);
$address = mysqli_real_escape_string($conn, $_POST['address']);

// 아이디 중복 검사
$sql_check = "SELECT * FROM logindata WHERE username='$username'";
$result = mysqli_query($conn, $sql_check);
if (mysqli_num_rows($result) > 0) {
    echo "<script>window.alert('이미 사용중인 아이디입니다.'); history.go(-1);</script>";
    exit;
}

// 비밀번호 확인
if ($password != $password2) {
    echo "<script>window.alert('비밀번호가 일치하지 않습니다.'); history.go(-1);</script>";
    exit;
}

$password_hash = password_hash($password, PASSWORD_DEFAULT);

// 사용자 정보 저장
$sql = "INSERT INTO logindata (username, password, password_hash) VALUES ('$username', '$password_hash', '$password_hash')";
if (mysqli_query($conn, $sql)) {
    echo '<h1>회원가입 성공</h1><p><a href="login.php"><h3>로그인 페이지로 돌아가기</h3></a></p>';
} else {
    echo '회원가입 실패: ' . mysqli_error($conn);
}
?>

회원 가입 시 'admin' or '1' = '1과 같은 username의 계정을 충분히 만들 수 있는 상황이다.

2. 마이페이지 (mypage.php)

DB에서 회원 정보를 아무 검사 없이 받아오고 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);

session_start();

// 로그인 체크
if (!isset($_SESSION['username'])) {
    echo "<script>alert('로그인이 필요합니다.'); location.href='login.php';</script>";
    exit;
}

// DB 연결
$conn = mysqli_connect('localhost', 'test22', 'pass22', 'logintest');
if (!$conn) {
    die('DB connect error: ' . mysqli_connect_error());
}

// DB에서 값 그대로 받아오기 (prepared statement 없이)
$username = $_SESSION['username'];
$sql = "SELECT * FROM logindata WHERE username='$username'";
$result = mysqli_query($conn, $sql);

if (!$result || mysqli_num_rows($result) == 0) {
    die('회원 정보를 찾을 수 없습니다.');
}

$user = mysqli_fetch_assoc($result);
mysqli_close($conn);
?>
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <link rel="stylesheet" href="login.css">
    <title>마이페이지</title>
</head>
<body>
    <div class='register-container'>
        <h1>회원 정보</h1>
        <form method="post" action="mypage_update.php">
            <input type="text" id="username" name="username" placeholder="사용자 이름:" 
                   value="<?php echo htmlspecialchars($user['username']); ?>" readonly>
            <input type="password" id="password" name="password" placeholder="새 비밀번호 (변경 시)">
            <input type="password" id="password2" name="password2" placeholder="새 비밀번호 확인">
            <input type="text" id="address" name="address" placeholder="주소:" 
                   value="<?php echo htmlspecialchars($user['address'] ?? ''); ?>">
            <button type="submit">정보 수정</button>
        </form>
    </div>
</body>
</html>

3. 회원정보 수정 페이지 (mypage_update.php)

선처리 질의문을 적용하지 않은 상태이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);

session_start();

if (!isset($_SESSION['username'])) {
    die('로그인이 필요합니다.');
}

$conn = mysqli_connect('localhost', 'test22', 'pass22', 'logintest');
if (!$conn) {
    die('DB connect error: ' . mysqli_connect_error());
}

// 입력값 받기
$username = $_SESSION['username'];
$password = $_POST['password'] ?? '';
$password2 = $_POST['password2'] ?? '';
$address = $_POST['address'] ?? '';

// 비밀번호 변경하는 경우
if (!empty($password)) {
    if ($password !== $password2) {
        echo "<script>alert('비밀번호가 일치하지 않습니다.'); history.back();</script>";
        exit;
    }
    
    $password_hash = password_hash($password, PASSWORD_DEFAULT);
    
    $sql = "UPDATE logindata SET password_hash='$password_hash', address='$address' WHERE username='$username'";
} else {
    $sql = "UPDATE logindata SET address='$address' WHERE username='$username'";
}

echo "실행할 쿼리: $sql<br><br>";

if (mysqli_query($conn, $sql)) {
    echo "<script>alert('회원정보가 수정되었습니다.'); location.href='mypage.php';</script>";
} else {
    echo '수정 실패: ' . mysqli_error($conn);
}

mysqli_close($conn);
?>

2차 SQLi 공격 시나리오

1단계: 악의적인 username으로 계정 생성

'admin' or '1' = '1 을 username으로 한 계정을 생성한다.

회원가입 페이지 회원가입 페이지. 입력값 이스케이프만 하고 있으므로 특수문자있는 상태 그대로 DB에 저장된다.

2단계: 마이페이지 접속

'admin' or '1' = '1 으로 로그인한 뒤 마이페이지에 접속한다.

마이페이지 ‘admin’ or ‘1’ = ‘1 계정의 마이페이지

마이페이지에서 'admin' or '1' = '1 계정의 정보를 DB에서 가져올 때, 선처리질의문이 적용되지 않은 상태이므로

1
SELECT * FROM logindata WHERE username=''admin' or '1' = '1'

쿼리가 실행된다. 이는 조건절이 항상 참이 되어 logindata의 모든 데이터를 가져오게 되고, 따라서 첫 행의 username인 good이 출력되고 있다.

3단계: 모든 계정 정보 변조

'admin' or '1' = '1 계정의 마이페이지에서 패스워드 변경 및 주소 변경을 한다.

mypage_update.php에서 'admin' or '1' = '1 username으로 인해 쿼리가 변경되어 아래와 같은 쿼리가 실행되게 된다:

1
2
UPDATE logindata SET password_hash='$password_hash', address='$address' 
WHERE username=''admin' or '1' = '1'

이 또한 조건절이 참인 쿼리가 되어 logindata의 모든 컬럼의 데이터 값이 수정된다.

공격 결과 모든 계정의 패스워드와 주소가 변경되었다

따라서 공격자가 admin 뿐만 아니라 모든 계정으로 로그인할 수 있는 상황이 만들어진다.

결론: 외부에서 입력값 받는 곳 뿐만 아니라 DB 서버 내부에서 받아오는 값까지 모두 선처리 질의문을 적용하자!

추가 보안 이슈

SQL 인젝션 공격과는 별개로 게시판 글 수정 페이지로 넘어갈 때, 게시판 글의 board_num 값을 GET으로 보내는데, 이 역시 클라이언트에서 조작해서 보낼 수 있는 값이라 서버 상에서 오류를 일으킬 여지가 된다.

해결 방법: 로그인한 계정의 세션 이용 또는 POST로 보내는 방식으로 변경


시큐어 코딩

이제 간단하게 2차 SQLi도 확인했으니 prepared statement를 적용해서 시큐어 코딩하면 아래와 같다.

1. 회원가입 기능 페이지 (register_process.php)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);

$conn = mysqli_connect('localhost', 'test22', 'pass22', 'logintest');
if (!$conn) {
    die('Could not connect: ' . mysqli_connect_error());
}
mysqli_set_charset($conn, 'utf8mb4');

// POST 데이터 가져오기 (이스케이프 불필요, prepared statement 사용)
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
$password2 = $_POST['password2'] ?? '';
$address = trim($_POST['address'] ?? '');

// 입력값 검증
if (empty($username) || empty($password)) {
    echo "<script>alert('아이디와 비밀번호를 입력하세요.'); history.go(-1);</script>";
    exit;
}

if (strlen($username) < 3 || strlen($username) > 20) {
    echo "<script>alert('아이디는 3~20자여야 합니다.'); history.go(-1);</script>";
    exit;
}

if (strlen($password) < 4) {
    echo "<script>alert('비밀번호는 4자 이상이어야 합니다.'); history.go(-1);</script>";
    exit;
}

// 비밀번호 확인
if ($password !== $password2) {
    echo "<script>alert('비밀번호가 일치하지 않습니다.'); history.go(-1);</script>";
    exit;
}

// Prepared statement로 아이디 중복 검사
$stmt = mysqli_prepare($conn, "SELECT username FROM logindata WHERE username = ? LIMIT 1");
mysqli_stmt_bind_param($stmt, 's', $username);
mysqli_stmt_execute($stmt);
mysqli_stmt_store_result($stmt);

if (mysqli_stmt_num_rows($stmt) > 0) {
    mysqli_stmt_close($stmt);
    echo "<script>alert('이미 사용중인 아이디입니다.'); history.go(-1);</script>";
    exit;
}
mysqli_stmt_close($stmt);

// 비밀번호 해싱
$password_hash = password_hash($password, PASSWORD_DEFAULT);

// Prepared statement로 사용자 정보 저장
$stmt = mysqli_prepare($conn, "INSERT INTO logindata (username, password, password_hash, address) VALUES (?, ?, ?, ?)");
mysqli_stmt_bind_param($stmt, 'ssss', $username, $password_hash, $password_hash, $address);

if (mysqli_stmt_execute($stmt)) {
    echo '<h1>회원가입 성공</h1><p><a href="login.php"><h3>로그인 페이지로 돌아가기</h3></a></p>';
} else {
    echo '회원가입 실패: ' . mysqli_error($conn);
}

mysqli_stmt_close($stmt);
mysqli_close($conn);
?>

2. 마이페이지 (mypage.php)

회원 정보 불러올 때도 선처리 질의문을 적용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);

session_start();

// 로그인 체크
if (!isset($_SESSION['username'])) {
    echo "<script>alert('로그인이 필요합니다.'); location.href='login.php';</script>";
    exit;
}

// DB 연결
$conn = mysqli_connect('localhost', 'test22', 'pass22', 'logintest');
if (!$conn) {
    die('DB connect error: ' . mysqli_connect_error());
}
mysqli_set_charset($conn, 'utf8mb4');

// Prepared statement로 사용자 정보 조회
$username = $_SESSION['username'];
$stmt = mysqli_prepare($conn, "SELECT username, address FROM logindata WHERE username = ? LIMIT 1");
mysqli_stmt_bind_param($stmt, 's', $username);
mysqli_stmt_execute($stmt);
$result = mysqli_stmt_get_result($stmt);

if (!$result || mysqli_num_rows($result) == 0) {
    mysqli_stmt_close($stmt);
    mysqli_close($conn);
    die('회원 정보를 찾을 수 없습니다.');
}

$user = mysqli_fetch_assoc($result);
mysqli_stmt_close($stmt);
mysqli_close($conn);
?>
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <link rel="stylesheet" href="login.css">
    <title>마이페이지</title>
</head>
<body>
    <div class='register-container'>
        <h1>회원 정보</h1>
        <form method="post" action="mypage_update.php">
            <input type="text" id="username" name="username" placeholder="사용자 이름:" 
                   value="<?php echo htmlspecialchars($user['username']); ?>" readonly>
            <input type="password" id="password" name="password" placeholder="새 비밀번호 (변경 시)">
            <input type="password" id="password2" name="password2" placeholder="새 비밀번호 확인">
            <input type="text" id="address" name="address" placeholder="주소:" 
                   value="<?php echo htmlspecialchars($user['address'] ?? ''); ?>">
            <button type="submit">정보 수정</button>
        </form>
    </div>
</body>
</html>

3. 회원정보 수정 페이지 (mypage_update.php)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);

session_start();

if (!isset($_SESSION['username'])) {
    die('로그인이 필요합니다.');
}

$conn = mysqli_connect('localhost', 'test22', 'pass22', 'logintest');
if (!$conn) {
    die('DB connect error: ' . mysqli_connect_error());
}
mysqli_set_charset($conn, 'utf8mb4');

// 입력값 받기
$username = $_SESSION['username'];
$password = $_POST['password'] ?? '';
$password2 = $_POST['password2'] ?? '';
$address = trim($_POST['address'] ?? '');

// 비밀번호 변경하는 경우
if (!empty($password)) {
    if ($password !== $password2) {
        mysqli_close($conn);
        echo "<script>alert('비밀번호가 일치하지 않습니다.'); history.back();</script>";
        exit;
    }
    
    if (strlen($password) < 4) {
        mysqli_close($conn);
        echo "<script>alert('비밀번호는 4자 이상이어야 합니다.'); history.back();</script>";
        exit;
    }
    
    $password_hash = password_hash($password, PASSWORD_DEFAULT);
    
    // Prepared statement로 비밀번호 + 주소 변경
    $stmt = mysqli_prepare($conn, "UPDATE logindata SET password_hash = ?, password = ?, address = ? WHERE username = ?");
    mysqli_stmt_bind_param($stmt, 'ssss', $password_hash, $password_hash, $address, $username);
    
} else {
    // Prepared statement로 주소만 변경
    $stmt = mysqli_prepare($conn, "UPDATE logindata SET address = ? WHERE username = ?");
    mysqli_stmt_bind_param($stmt, 'ss', $address, $username);
}

if (mysqli_stmt_execute($stmt)) {
    mysqli_stmt_close($stmt);
    mysqli_close($conn);
    echo "<script>alert('회원정보가 수정되었습니다.'); location.href='mypage.php';</script>";
} else {
    echo '수정 실패: ' . mysqli_error($conn);
    mysqli_stmt_close($stmt);
    mysqli_close($conn);
}
?>

핵심 보안 개선 사항

  1. Prepared Statement 사용: 모든 SQL 쿼리에 prepared statement 적용
  2. 입력값 검증: 길이, 형식 등 서버 사이드 검증 추가
  3. 에러 처리 강화: mysqli_report 설정으로 에러 추적 개선
  4. 문자셋 설정: UTF-8 명시적 설정으로 인코딩 공격 방지
  5. LIMIT 절 추가: 불필요한 다중 레코드 조회 방지
This post is licensed under CC BY 4.0 by the author.