Devin.KR

웹 보안 기본기 - 비밀번호·CSRF·세션

개발자KR 조회 0

이 장에서 배우는 것

앞 장에서는 트랜잭션과 리포지토리로 데이터를 안전하게 저장하는 법을 다뤘다. 이 장은 그 데이터에 접근하는 입구, 곧 로그인과 폼 전송과 화면 출력을 다룬다. 수강 신청 시스템에 로그인 기능을 붙이면 비밀번호 저장, 다른 사이트에서 날아오는 위조 요청, 세션 탈취, 화면에 섞여 드는 악성 문자열이 한꺼번에 문제가 된다. 각각 PHP 내장 함수 몇 개로 막을 수 있다. 다만 어느 지점에서 어떤 함수를 부르는지 알아야 한다.

  • password_hash, password_verify, password_needs_rehash 로 비밀번호를 저장하고 검증하며, 비용을 올릴 때 기존 해시를 점진적으로 교체한다.
  • CSRF 토큰을 발급하고 hash_equals 로 검증한다.
  • 로그인 직후 세션 ID 를 교체해 세션 고정을 막는다.
  • 출력 문맥(HTML, URL, JavaScript)마다 알맞은 이스케이프 함수를 고른다.
  • SQL 주입이 일어나는 코드와 막는 코드를 나란히 비교해 확인한다.

문제 상황

동네 학원의 수강 신청 화면을 처음 만들 때는 이런 코드가 나오기 쉽다. 회원 테이블에 비밀번호를 평문으로 두고, 로그인 폼은 이메일과 비밀번호만 받아 바로 처리한다. 세션은 PHP 가 만들어 준 ID 를 그대로 쓰고, 강좌 이름은 DB 에서 꺼내 그대로 HTML 에 찍는다. 검색 쿼리는 문자열을 이어 붙여 만든다.

이 상태에서 일어날 수 있는 일은 구체적이다. 데이터베이스 파일이 유출되면 모든 수강생의 비밀번호가 그대로 노출된다. 수강생이 학원 사이트에 로그인한 채 다른 사이트를 열었는데, 그 사이트에 숨겨진 폼이 학원의 수강 취소 주소로 자동 전송된다. 브라우저가 쿠키를 알아서 붙여 주므로 서버는 정상 요청으로 받아들인다. 강사가 강좌 이름에 <script> 를 넣으면 목록을 보는 모든 사람의 브라우저에서 실행된다. 검색창에 따옴표가 섞인 문자열을 넣으면 SQL 문의 구조가 바뀐다.

네 가지는 서로 다른 문제이고 방어 지점도 다르다. 비밀번호는 저장 시점, CSRF 와 세션은 요청 처리 시점, 이스케이프는 출력 시점, SQL 주입은 쿼리를 만드는 시점에 막는다. 이 장은 이 네 지점을 하나씩 코드로 확인한다.

비밀번호는 저장하지 않고 검증한다

비밀번호 보관의 원칙은 원문을 복원할 수 없는 값만 저장하는 것이다. md5 나 sha256 같은 범용 해시(hash)는 빠르게 계산하도록 설계됐다. 유출된 값에 대해 초당 수십억 번의 대입을 시도할 수 있어서 비밀번호 용도로는 맞지 않다. PHP 의 password_hash 는 일부러 느리고, 호출할 때마다 무작위 솔트(salt)를 만들어 결과 문자열 안에 함께 넣는다.

bcrypt 해시는 60자이고 구조는 이렇다. 앞의 $2y$ 는 알고리즘, 그다음 두 자리 숫자는 비용(cost), 이어지는 22자는 솔트, 나머지 31자는 해시값이다. 같은 비밀번호라도 호출할 때마다 결과가 달라진다. 그래서 저장한 해시와 문자열 비교를 할 수 없고, 해시에서 솔트와 비용을 읽어 다시 계산해 주는 password_verify 를 써야 한다.

비밀번호 함수 세 가지의 역할
함수호출 시점입력반환
password_hash가입, 비밀번호 변경, 재해시평문, 알고리즘, 옵션솔트가 포함된 해시 문자열
password_verify로그인평문, 저장된 해시일치 여부(bool)
password_needs_rehash로그인 성공 직후저장된 해시, 알고리즘, 옵션해시를 새 설정으로 바꿔야 하는지(bool)

컴퓨터가 빨라지면 같은 비용으로는 방어력이 떨어지므로 비용을 올려야 할 때가 온다. 이미 저장된 해시는 평문을 모르니 서버가 일괄 변환할 수 없다. 그래서 로그인에 성공해 평문을 손에 쥔 순간에 password_needs_rehash 로 확인하고, true 이면 새 설정으로 다시 해시해 저장한다. 사용자는 아무것도 하지 않아도 되고, 자주 로그인하는 사용자부터 새 비용으로 바뀐다.

로그인 성공 직후에 해시를 점검해 필요하면 새 비용으로 교체한 뒤 세션을 시작한다

두 가지를 더 기억한다. bcrypt 는 입력의 처음 72바이트만 사용한다. 매우 긴 비밀번호를 받는 서비스라면 길이 상한을 두는 편이 낫다. 또 존재하지 않는 이메일로 로그인을 시도하면 검증을 건너뛰어 응답이 빨라지기 때문에, 계정 존재 여부가 응답 시간으로 드러날 수 있다. 이 장의 예제는 없는 계정에도 더미 해시로 password_verify 를 한 번 실행해 시간을 맞춘다. 응답 메시지도 이메일이 틀렸는지 비밀번호가 틀렸는지 구분하지 않는다.

공식 문서는 password 확장 문서에서 확인할 수 있다.

CSRF 토큰과 세션 고정

CSRF 토큰

교차 사이트 요청 위조(CSRF, Cross-Site Request Forgery)는 사용자의 브라우저가 쿠키를 자동으로 붙여 보낸다는 점을 이용한다. 서버는 요청이 학원 화면의 폼에서 왔는지, 다른 사이트의 폼에서 왔는지 쿠키만으로는 구별하지 못한다. 방어는 공격자가 알 수 없는 값을 폼에 심고, 전송될 때 그 값을 확인하는 것이다.

순서는 이렇다. 폼을 보여 줄 때 random_bytes 로 토큰을 만들어 세션에 저장하고 폼의 숨은 필드로 내보낸다. 폼이 전송되면 세션에 저장한 값과 전송된 값을 비교한다. 이 비교에는 hash_equals 를 쓴다. 일반 비교 연산은 앞쪽부터 달라지는 지점에서 멈추므로 응답 시간 차이로 값을 조금씩 추측당할 여지가 있다. hash_equals 는 길이가 같으면 끝까지 같은 시간으로 비교한다. 첫 인자에는 서버가 알고 있는 값, 두 번째 인자에는 사용자가 보낸 값을 둔다. 세션에 토큰이 없을 때 빈 문자열끼리 일치해 통과하는 일이 없도록, 저장된 값이 비어 있으면 먼저 거절한다.

상태를 바꾸는 요청은 GET 이 아니라 POST 로 받아야 한다. GET 은 이미지 태그 하나로도 유발할 수 있어서 토큰 검증을 거치기 어렵다. 쿠키의 SameSite 속성은 보조 방어이지 토큰의 대체 수단이 아니다.

세션 고정과 ID 교체

세션 고정(session fixation)은 공격자가 자기가 아는 세션 ID 를 피해자에게 심는 공격이다. 피해자가 그 ID 로 로그인하면 서버는 그 세션에 사용자 정보를 기록한다. 공격자는 이미 아는 ID 로 접속만 하면 로그인된 상태가 된다. 방어는 권한이 바뀌는 순간, 곧 로그인 성공 직후에 세션 ID 를 새로 발급하고 이전 ID 를 폐기하는 것이다. PHP 에서는 session_regenerate_id(true) 가 그 일을 한다. 인자 true 는 이전 세션 데이터를 삭제하라는 뜻이다.

로그인 뒤에 세션 ID 를 교체하면 공격자가 심어 둔 ID 는 쓸모가 없어진다

이 장의 완성 코드는 CLI 에서 결정적으로 실행하기 위해 세션 저장소를 직접 흉내 낸다. 실제 웹 환경에서는 같은 흐름이 다음 코드로 바뀐다. 아래는 비밀번호 확인을 생략하고 흐름만 보이는 최소 예다.

<?php
declare(strict_types=1);

session_set_cookie_params(['httponly' => true, 'samesite' => 'Lax']);
session_start();

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $sent = (string) ($_POST['csrf'] ?? '');
    $saved = (string) ($_SESSION['csrf'] ?? '');
    if ($saved === '' || hash_equals($saved, $sent) === false) {
        http_response_code(403);
        exit('csrf rejected');
    }
    session_regenerate_id(true);
    $_SESSION['user_id'] = 1;
    exit('login ok');
}

$_SESSION['csrf'] = bin2hex(random_bytes(32));
$token = htmlspecialchars($_SESSION['csrf'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
echo '<form method="post"><input type="hidden" name="csrf" value="' . $token . '"><button>login</button></form>';

출력 문맥별 이스케이프와 SQL 주입

이스케이프(escape)는 데이터가 코드로 해석되지 않도록 문자를 바꾸는 일이다. 같은 문자열이라도 출력되는 자리에 따라 특별한 문자가 달라서, 하나의 만능 함수는 없다. 규칙은 두 가지다. 저장할 때가 아니라 출력할 때, 그리고 그 자리의 문맥에 맞는 함수로 처리한다.

출력 문맥에 따라 쓰는 함수
출력 자리함수바뀌는 문자 예
HTML 본문과 따옴표로 감싼 속성값htmlspecialchars(ENT_QUOTES)& < > " '
URL 의 쿼리 값rawurlencode공백, &, 따옴표, 슬래시 등
script 안의 JavaScript 값json_encode(JSON_HEX_* 플래그)< > & " '

URL 을 속성에 넣을 때는 두 단계가 겹친다. 쿼리 값에 rawurlencode 를 적용하고, 완성된 URL 전체를 htmlspecialchars 로 한 번 더 처리한다. 사용자가 입력한 값을 href 의 주소 전체로 쓰는 경우에는 javascript: 로 시작하는 주소가 문제가 되므로, http 와 https 로 시작하는지 먼저 확인해야 한다. 이스케이프 함수만으로는 막을 수 없다.

SQL 주입은 앞 장에서 이미 준비된 문(prepared statement)으로 다뤘다. 다시 확인할 핵심은 이 방어가 값에만 통한다는 점이다. 자리표시자는 값 자리에만 쓸 수 있다. 테이블 이름, 컬럼 이름, 정렬 방향처럼 구조에 해당하는 부분은 바인딩할 수 없으므로 허용 목록에서 골라 이어 붙인다. 사용자 입력을 직접 이어 붙이면 안 된다.

완성 코드

한 파일 main.php 로 구성했다. 세션 저장소는 메모리 배열로 흉내 내고, 요청은 배열로 표현한다. 해시와 세션 ID 와 토큰은 매번 달라지므로 출력에는 값이 아니라 접두사, 길이, 비교 결과만 찍는다.

main.php

<?php
declare(strict_types=1);

const TARGET_COST = 10;
const LEGACY_COST = 4;

final class SessionStore
{
    /** @var array<string, array<string, mixed>> */
    private array $data = [];

    public function open(?string $id): string
    {
        $id ??= bin2hex(random_bytes(16));
        $this->data[$id] ??= [];
        return $id;
    }

    public function get(string $id, string $key): mixed
    {
        return $this->data[$id][$key] ?? null;
    }

    public function put(string $id, string $key, mixed $value): void
    {
        $this->data[$id][$key] = $value;
    }

    public function regenerate(string $id): string
    {
        $newId = bin2hex(random_bytes(16));
        $this->data[$newId] = $this->data[$id] ?? [];
        unset($this->data[$id]);
        return $newId;
    }
}

function hashPassword(string $plain, int $cost = TARGET_COST): string
{
    return password_hash($plain, PASSWORD_BCRYPT, ['cost' => $cost]);
}

function needsRehash(string $hash): bool
{
    return password_needs_rehash($hash, PASSWORD_BCRYPT, ['cost' => TARGET_COST]);
}

function dummyHash(): string
{
    static $hash = null;
    return $hash ??= hashPassword('dummy-not-a-real-password');
}

function createDb(): PDO
{
    $pdo = new PDO('sqlite::memory:', null, null, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]);
    $pdo->exec('CREATE TABLE users (
        id INTEGER PRIMARY KEY,
        email TEXT NOT NULL UNIQUE,
        password_hash TEXT NOT NULL
    )');
    $insert = $pdo->prepare('INSERT INTO users (email, password_hash) VALUES (:email, :hash)');
    $insert->execute(['email' => 'kim@academy.test', 'hash' => hashPassword('correct-horse', LEGACY_COST)]);
    $insert->execute(['email' => 'lee@academy.test', 'hash' => hashPassword('pass-1234')]);
    return $pdo;
}

function authenticate(PDO $pdo, string $email, string $password): ?int
{
    $stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = :email');
    $stmt->execute(['email' => $email]);
    $row = $stmt->fetch();

    $hash = $row === false ? dummyHash() : $row['password_hash'];
    $matched = password_verify($password, $hash);
    if ($row === false || $matched === false) {
        return null;
    }

    if (needsRehash($hash)) {
        $update = $pdo->prepare('UPDATE users SET password_hash = :hash WHERE id = :id');
        $update->execute(['hash' => hashPassword($password), 'id' => $row['id']]);
    }
    return (int) $row['id'];
}

function verifyCsrf(mixed $saved, string $sent): bool
{
    return is_string($saved) && strlen($saved) > 0 && hash_equals($saved, $sent);
}

/**
 * @param array<string, mixed> $req
 * @return array{status: int, sid: string, body: string}
 */
function handleRequest(PDO $pdo, SessionStore $store, array $req): array
{
    $cookie = $req['cookies']['sid'] ?? null;
    $sid = $store->open(is_string($cookie) ? $cookie : null);
    $route = $req['method'] . ' ' . $req['path'];

    if ($route === 'GET /login') {
        $token = bin2hex(random_bytes(32));
        $store->put($sid, 'csrf', $token);
        return ['status' => 200, 'sid' => $sid, 'body' => $token];
    }

    if ($route === 'POST /login') {
        $sent = (string) ($req['post']['csrf'] ?? '');
        if (verifyCsrf($store->get($sid, 'csrf'), $sent) === false) {
            return ['status' => 403, 'sid' => $sid, 'body' => 'csrf rejected'];
        }
        $userId = authenticate(
            $pdo,
            (string) ($req['post']['email'] ?? ''),
            (string) ($req['post']['password'] ?? '')
        );
        if ($userId === null) {
            return ['status' => 401, 'sid' => $sid, 'body' => 'login failed'];
        }
        $sid = $store->regenerate($sid);
        $store->put($sid, 'user_id', $userId);
        $store->put($sid, 'csrf', bin2hex(random_bytes(32)));
        return ['status' => 200, 'sid' => $sid, 'body' => 'login ok'];
    }

    if ($route === 'GET /me') {
        $userId = $store->get($sid, 'user_id');
        if ($userId === null) {
            return ['status' => 401, 'sid' => $sid, 'body' => 'anonymous'];
        }
        return ['status' => 200, 'sid' => $sid, 'body' => 'user ' . $userId];
    }

    return ['status' => 404, 'sid' => $sid, 'body' => 'not found'];
}

/** @return array<string, mixed> */
function request(string $method, string $path, ?string $sid = null, array $post = []): array
{
    return [
        'method' => $method,
        'path' => $path,
        'cookies' => $sid === null ? [] : ['sid' => $sid],
        'post' => $post,
    ];
}

function e(string $text): string
{
    return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function jsLiteral(string $text): string
{
    return json_encode(
        $text,
        JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
            | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
    );
}

function tf(bool $value): string
{
    return $value ? 'true' : 'false';
}

$pdo = createDb();
$store = new SessionStore();

echo "== 1. password_hash ==\n";
$hash = hashPassword('pass-1234');
echo '접두사: ', substr($hash, 0, 7), "\n";
echo '길이: ', strlen($hash), "\n";
echo '올바른 비밀번호: ', tf(password_verify('pass-1234', $hash)), "\n";
echo '틀린 비밀번호: ', tf(password_verify('pass-9999', $hash)), "\n";
echo '같은 입력, 같은 해시: ', tf(hashPassword('pass-1234') === $hash), "\n";

echo "== 2. 비용 올리기 ==\n";
$stored = (string) $pdo->query('SELECT password_hash FROM users WHERE id = 1')->fetchColumn();
echo '기존 접두사: ', substr($stored, 0, 7), "\n";
echo '재해시 필요(기존): ', tf(needsRehash($stored)), "\n";
echo '재해시 필요(현재): ', tf(needsRehash($hash)), "\n";
echo '로그인 결과: ', authenticate($pdo, 'kim@academy.test', 'correct-horse') ?? 'null', "\n";
$stored = (string) $pdo->query('SELECT password_hash FROM users WHERE id = 1')->fetchColumn();
echo '저장된 접두사: ', substr($stored, 0, 7), "\n";

echo "== 3. CSRF 와 세션 고정 ==\n";
$planted = $store->open(null);
$form = handleRequest($pdo, $store, request('GET', '/login', $planted));
$token = $form['body'];
echo '폼 요청: ', $form['status'], "\n";
echo '세션 유지: ', tf($form['sid'] === $planted), "\n";

$login = ['email' => 'lee@academy.test', 'password' => 'pass-1234'];
$res = handleRequest($pdo, $store, request('POST', '/login', $planted, $login));
echo '토큰 없이 전송: ', $res['status'], "\n";
$res = handleRequest($pdo, $store, request('POST', '/login', $planted, $login + ['csrf' => str_repeat('0', 64)]));
echo '다른 토큰으로 전송: ', $res['status'], "\n";
$wrong = ['email' => 'lee@academy.test', 'password' => 'nope', 'csrf' => $token];
$res = handleRequest($pdo, $store, request('POST', '/login', $planted, $wrong));
echo '틀린 비밀번호: ', $res['status'], "\n";
$res = handleRequest($pdo, $store, request('POST', '/login', $planted, $login + ['csrf' => $token]));
echo '로그인 성공: ', $res['status'], "\n";
$newSid = $res['sid'];
echo '세션 ID 교체: ', tf($newSid !== $planted), "\n";
echo '이전 세션의 사용자: ', $store->get($planted, 'user_id') ?? '없음', "\n";
$me = handleRequest($pdo, $store, request('GET', '/me', $newSid));
echo '새 세션 /me: ', $me['status'], ' ', $me['body'], "\n";
$me = handleRequest($pdo, $store, request('GET', '/me', $planted));
echo '이전 세션 /me: ', $me['status'], ' ', $me['body'], "\n";

echo "== 4. 출력 문맥 ==\n";
$input = "O'Neil <b>\"A&B\"</b>";
echo '입력: ', $input, "\n";
echo 'HTML 본문/속성: ', e($input), "\n";
echo 'URL 쿼리 값: ', rawurlencode($input), "\n";
echo 'JS 리터럴: ', jsLiteral($input), "\n";

echo "== 5. SQL 주입 ==\n";
$attack = "x' OR '1'='1";
$unsafe = "SELECT COUNT(*) FROM users WHERE email = '" . $attack . "'";
echo '이어붙인 쿼리: ', (int) $pdo->query($unsafe)->fetchColumn(), "\n";
$safe = $pdo->prepare('SELECT COUNT(*) FROM users WHERE email = :email');
$safe->execute(['email' => $attack]);
echo '바인딩한 쿼리: ', (int) $safe->fetchColumn(), "\n";

줄별 해설

SessionStore: PHP 세션의 동작을 최소한으로 흉내 낸 클래스다. open 은 ID 를 받으면 그대로 쓰고, 없으면 새로 만든다. 실제 PHP 의 기본 설정이 모르는 ID 도 받아들이기 때문에 세션 고정이 성립한다는 점을 그대로 재현한 것이다. regenerate 는 데이터를 새 ID 로 옮기고 이전 ID 를 지운다. session_regenerate_id(true) 와 같은 효과다.

hashPassword, needsRehash: 알고리즘과 비용을 한 곳에 모았다. 비용을 올릴 때 TARGET_COST 상수만 바꾸면 모든 호출이 따라간다. 두 함수의 옵션이 다르면 needs_rehash 가 항상 true 를 돌려주므로 같은 상수를 공유하는 것이 중요하다. LEGACY_COST 는 예전에 낮은 비용으로 저장된 계정을 만들기 위한 값이다.

dummyHash: 없는 계정에도 password_verify 를 실행하기 위한 값이다. static 변수에 한 번만 만들어 둔다.

createDb: 메모리 SQLite 에 users 테이블을 만든다. 오류는 예외로 받고 조회 결과는 연관 배열로 받는다. kim 은 비용 4 로, lee 는 현재 비용으로 저장한다.

authenticate: 이메일은 바인딩으로 조회한다. 계정이 없으면 더미 해시로 검증해 시간을 맞춘 뒤 null 을 돌려준다. 검증에 성공하면 needsRehash 로 점검하고, 필요하면 방금 받은 평문으로 다시 해시해 UPDATE 한다. 평문을 쥐고 있는 이 시점이 유일한 교체 기회다.

verifyCsrf: 세션 값이 문자열이고 비어 있지 않을 때만 hash_equals 로 비교한다. 첫 인자가 서버 쪽 값이다.

handleRequest: 요청 배열에서 쿠키를 읽어 세션을 연다. GET /login 은 토큰을 만들어 세션에 저장하고 본문으로 돌려준다. 실제 화면에서는 이 값이 폼의 숨은 필드가 된다. POST /login 은 토큰 검증, 비밀번호 인증 순서로 진행한다. 토큰 검증을 먼저 하면 위조 요청이 비밀번호 검증 비용을 쓰게 만들 수 없다. 둘을 통과하면 regenerate 로 ID 를 교체하고, 새 세션에 사용자와 새 토큰을 기록한다. 실패 응답은 세션 ID 를 바꾸지 않는다.

e, jsLiteral: HTML 과 JavaScript 문맥용 래퍼다. e 는 따옴표까지 바꾸는 ENT_QUOTES 를 명시한다. jsLiteral 의 HEX 플래그는 꺾쇠, 앰퍼샌드, 따옴표를 \uXXXX 로 바꿔 script 태그를 닫거나 속성을 빠져나오는 문자열을 무력화한다. JSON_THROW_ON_ERROR 덕분에 반환이 항상 문자열이다.

시나리오 3: planted 는 공격자가 방문해서 얻은 ID 라고 보면 된다. 피해자가 이 ID 가 담긴 쿠키로 폼을 요청하고, 토큰 없이, 엉뚱한 토큰으로, 틀린 비밀번호로 시도한 뒤 마지막에 정상 전송한다. 성공한 응답의 sid 는 planted 와 다르고, planted 세션에는 사용자 정보가 없다.

시나리오 4, 5: 같은 입력이 문맥마다 다르게 바뀌는 모습을 보여 준다. SQL 비교에서는 이어 붙인 쿼리가 WHERE 조건을 항상 참으로 만들어 두 행을 모두 세고, 바인딩한 쿼리는 문자열 전체를 하나의 값으로 취급해 0 을 돌려준다. 첫 번째 쿼리는 일부러 취약하게 만든 비교용이므로 실제 코드에 옮기면 안 된다.

실행 결과

$ php main.php
== 1. password_hash ==
접두사: $2y$10$
길이: 60
올바른 비밀번호: true
틀린 비밀번호: false
같은 입력, 같은 해시: false
== 2. 비용 올리기 ==
기존 접두사: $2y$04$
재해시 필요(기존): true
재해시 필요(현재): false
로그인 결과: 1
저장된 접두사: $2y$10$
== 3. CSRF 와 세션 고정 ==
폼 요청: 200
세션 유지: true
토큰 없이 전송: 403
다른 토큰으로 전송: 403
틀린 비밀번호: 401
로그인 성공: 200
세션 ID 교체: true
이전 세션의 사용자: 없음
새 세션 /me: 200 user 2
이전 세션 /me: 401 anonymous
== 4. 출력 문맥 ==
입력: O'Neil <b>"A&B"</b>
HTML 본문/속성: O&#039;Neil &lt;b&gt;&quot;A&amp;B&quot;&lt;/b&gt;
URL 쿼리 값: O%27Neil%20%3Cb%3E%22A%26B%22%3C%2Fb%3E
JS 리터럴: "O\u0027Neil \u003Cb\u003E\u0022A\u0026B\u0022\u003C/b\u003E"
== 5. SQL 주입 ==
이어붙인 쿼리: 2
바인딩한 쿼리: 0

비밀번호 해시를 여러 번 계산하므로 실행에 1초 안팎이 걸릴 수 있다.

브라우저에서 확인하기

앞서 보인 웹용 최소 예를 index.php 로 저장하고 내장 서버를 띄운다.

$ php -S 127.0.0.1:8000 index.php

브라우저에서 http://127.0.0.1:8000 을 열고 개발자 도구의 네트워크 탭을 본다. 첫 응답에 PHPSESSID 쿠키가 생기고, 버튼을 누르면 로그인 응답에서 새 PHPSESSID 가 발급된다. 폼의 숨은 필드 값을 지우거나 바꿔 보내면 403 이 돌아온다. 내장 서버는 개발용이므로 운영에는 쓰지 않는다. 운영에서는 HTTPS 를 쓰고 쿠키에 secure 속성을 추가한다.

실무에서 자주 틀리는 것

비밀번호를 범용 해시로 저장하거나 문자열로 비교한다

틀린 코드:

$stored = hash('sha256', $password);
if ($stored === $row['password_hash']) { /* 로그인 */ }

솔트가 없어 같은 비밀번호는 같은 값이 되고, 계산이 빨라 대입 공격에 약하다. 고친 코드:

$stored = password_hash($password, PASSWORD_BCRYPT, ['cost' => 10]);
if (password_verify($password, $row['password_hash'])) { /* 로그인 */ }

CSRF 토큰을 일반 비교로 확인하거나 비어 있는 값을 허용한다

틀린 코드:

if (($_SESSION['csrf'] ?? '') == ($_POST['csrf'] ?? '')) { /* 처리 */ }

세션에 토큰이 없고 폼에도 없으면 빈 문자열끼리 같아 통과한다. 비교 방식도 시간 차이를 고려하지 않았다. 고친 코드:

$saved = (string) ($_SESSION['csrf'] ?? '');
$sent = (string) ($_POST['csrf'] ?? '');
if ($saved !== '' && hash_equals($saved, $sent)) { /* 처리 */ }

로그인 후에 세션 ID 를 그대로 둔다

틀린 코드:

session_start();
$_SESSION['user_id'] = $userId;

로그인 전에 부여된 ID 가 로그인 후에도 유지되어 세션 고정에 노출된다. 고친 코드:

session_start();
session_regenerate_id(true);
$_SESSION['user_id'] = $userId;

관리자 권한으로 올라가는 시점이나 비밀번호를 바꾼 시점에도 같은 방식으로 교체한다.

정렬 기준 같은 구조를 이어 붙인다

틀린 코드:

$sql = 'SELECT * FROM courses ORDER BY ' . $_GET['sort'];

자리표시자는 컬럼 이름에 쓸 수 없어서 이어 붙이게 되는데, 이때 입력이 그대로 SQL 이 된다. 고친 코드:

$allowed = ['title' => 'title', 'start' => 'starts_on'];
$sql = 'SELECT * FROM courses ORDER BY ' . ($allowed[$_GET['sort'] ?? ''] ?? 'title');

한눈에 보기

위협별 방어 지점과 도구
위협방어 시점사용하는 것확인 방법
비밀번호 유출저장과 로그인password_hash, password_verify, password_needs_rehash접두사와 길이, 재해시 후 접두사
CSRF폼 발급과 전송random_bytes, hash_equals토큰 없음과 불일치가 403
세션 고정로그인 직후session_regenerate_id(true)ID 변경, 이전 ID 에 사용자 없음
스크립트 삽입출력htmlspecialchars, rawurlencode, json_encode문맥별 출력 문자열
SQL 주입쿼리 작성바인딩, 구조는 허용 목록공격 문자열의 결과 건수

연습 문제

  1. 비용 10 으로 만든 해시를 목표 비용 12 로 점검하면 password_needs_rehash 는 무엇을 돌려주는가. 그 경우 로그인 성공 후 해야 할 일을 코드로 쓰시오.
  2. hash_equals 의 첫 번째 인자에 사용자가 보낸 값을 넣으면 어떤 점이 달라지는지, 그리고 길이가 다른 두 문자열을 넣으면 어떤 값이 나오는지 설명하시오.
  3. 검색어 $q 를 링크 /search?q=... 의 href 속성에 넣어 HTML 로 출력하는 코드를 쓰시오.
  4. 정렬 키 문자열을 받아 ORDER BY 절을 돌려주는 함수를 허용 목록 방식으로 쓰시오. 허용 목록에 없으면 title 로 정렬한다.

정답과 해설

1. true 를 돌려준다. 해시에 기록된 비용이 옵션의 비용과 다르기 때문이다. password_verify 가 성공한 직후에 새 해시를 만들어 저장한다.

if (password_verify($plain, $hash)) {
    if (password_needs_rehash($hash, PASSWORD_BCRYPT, ['cost' => 12])) {
        $new = password_hash($plain, PASSWORD_BCRYPT, ['cost' => 12]);
        // UPDATE users SET password_hash = :new WHERE id = :id
    }
}

평문은 검증이 성공한 순간에만 손에 있으므로 재해시를 이 자리에서 해야 한다.

2. 인자 순서 자체가 결과를 바꾸지는 않지만, 공식 문서는 첫 인자에 알려진 값, 둘째 인자에 사용자 값을 두도록 안내한다. 길이가 다를 때 계산 시간이 첫 인자의 길이에 맞춰지기 때문에, 사용자 값을 첫 인자에 두면 비교 시간이 사용자 입력 길이에 좌우된다. 길이가 다르면 false 를 돌려준다. 이 장의 verifyCsrf 는 서버 값을 첫 인자로 쓴다.

3. 쿼리 값을 먼저 URL 인코딩하고, 완성된 문자열을 HTML 속성용으로 이스케이프한다.

$url = '/search?q=' . rawurlencode($q);
echo '<a href="' . htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '">검색</a>';

URL 문맥과 HTML 문맥이 겹치므로 두 단계가 모두 필요하다. 하나만 쓰면 다른 문맥의 특수 문자가 남는다.

4.

function orderClause(string $key): string
{
    $allowed = ['title' => 'title', 'start' => 'starts_on'];
    return 'ORDER BY ' . ($allowed[$key] ?? 'title');
}

이어 붙이는 문자열은 코드 안에 적힌 컬럼 이름뿐이고, 사용자 입력은 배열의 키를 찾는 데만 쓰인다. 목록에 없는 입력은 기본값으로 대체되므로 어떤 문자열이 와도 SQL 구조가 바뀌지 않는다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.