django로 csrf token의 post request 전달하는 방법

운영중인 서버의 was(fcgi)가 가끔 비정상적으로 동작한다.

crontab에 등록하여 일주일에 한번씩 was(fcgi를 restart하고 있지만, 그래도 문제가 발생한다. restart 주기를 더 짧게 설정하는 방법보다 문제가 발생했을때 restart하는것이 효율적일것이다. curl을 이용하여 실질적으로 login하는 과정을 거쳐 main page(/bom/register/)가 response status 200이 아니면 restart 하게끔 script 작성하고자 한다.

curl을 이용한 login

현재 서버의 login page는 http://xxx.xxx.xxx/login/ 이다. 해당 page를 body(post)값으로 username과 password 변수를 같이 넘겨준다면 login이 될것이다.

# curl http://xxx.xxx.xxx/login/ -d "username={ID}&password={PW}"

그런데 403 FORBIDDEN이 뜬다. 원인은 CRSF verification failed다.

<h1>Forbidden <span>(403)</span></h1>
  <p>CSRF verification failed. Request aborted.</p>

CSRF

사이트 간 요청 위조(또는 크로스 사이트 요청 위조, 영어: Cross-site request forgery, CSRF, XSRF)는 웹사이트 취약점 공격의 하나로, 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위(수정, 삭제, 등록 등)를 특정 웹사이트에 요청하게 하는 공격을 말한다.

예를 들자면 아래와 같은 공격이다.

  1. 사용자가 xxx.xxx.xxx에 login을 한다. sessionid등의 쿠키값을 정상적으로 발급받아 login된다.
  2. 공격자가 mail이나 게시판등을 이용해 악의적인 http request의 주소를 사용자쪽으로 전달한다. 예를 든다면 http://xxx.xxx.xxx/changepassword?password=abc와 같은 request를 서버로 전송하게끔.
  3. 사용자가 해당 주소를 실행하여 원하지 않는 request를 전송하게 된다.
  4. 서버입장에선 로그인을 거친 정상적인 client가 request를 수행한것이니 해당 프로세스를 수행한다.

위 문제를 해결 하기 위해선 changpassword 페이지의 form전달값에 특정한 값을 추가하면 된다.

예를 들어 사용자로 부터 captcha를 값을 입력받게하여, 정상적인 페이지에서의 요청인지 확인하면 되는것이다. django에서는 간단히 crsf token을 발행하여 처리하게 한다.

CSRF TOKEN in DJANGO

form를 가진 django template에 아래와 같이 crsf_input을 설정한다.

<form action="" method="post">{{ csrf_input }}
<input name="username" id="username" type="text" />
<input name="password" id="password" type="password" />

이렇게 되면 아래와 같은 client에서는 아래와 같은 response를 받게 된다.

<form action="" method="post"><input type='hidden' name='csrfmiddlewaretoken' value='d7f6f683188d35958b0f453f6849a8d7' />

csrfmiddlewaretoken라는 이름의 hidden변수에 random 생성된 token값이 날라온다. http header에도 동일한 token을 cookie로 저장하게끔 되어 있다.

Set-Cookie: csrftoken=d7f6f683188d35958b0f453f6849a8d7; expires=Mon, 10-Jul-2017 08:32:17 GMT; Max-Age=31449600; Path=/

따라서 request시 cookie와 body에 위와 동일한 token값을 같이 전송해야 한다. django에서 해당 token값이 없거나 잘못되었을 경우 위처럼 403을 띄우게 되는것이다.

CURL을 이용하여 CRSF token 전송

CSRF token을 받아와야하니 한번의 login page request로는 불가능하다. 아래와 같은 과정이 있어야 할것이다.

  1. http://xxx.xxx.xxx/login/ 요청후에 csrf token값 저장
  2. 전달받은 csrf token값을 username, password와 같이 전달. cookie도 마찬가지.

curl을 이용하여 csrf token값을 저장해보자.

# curl -c cookie.txt http://xxx.xxx.xxx/login/

-c 옵션으로 set-cookie를 처리할 수 있다. 즉 cookie.txt가 생성되고 csrf token값이 저장된다. 파일을 열어보면 아래와 같다.

# Netscape HTTP Cookie File
# http://curl.haxx.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
xxx.xxx.xxx FALSE   /   FALSE   1499671503  csrftoken   d7f6f683188d35958b0f453f6849a8d7

이제 해당 cookie값과 post값으로 csrf token값을 전달하면 된다. -b 옵션으로 저장된 cookie을 서버로 전달할수 있다.

# curl -b cookie.txt http://xxx.xxx.xxx/login/ -d "username={ID}&password={PW}&csrfmiddlewaretoken=d7f6f683188d35958b0f453f6849a8d7"

이제 정상적으로 login이 됨을 확인할수 있다. 그런데 /bom/register/ 페이지가 정상적으로 열리지 않는다. login후에 sessionid을 cookie로 처리해야하는데, 그 과정이 없으니 login이 되지 않은 것과 동일한 것이다. 위 login request시 sessionid도 response header로 아래와 넘어 온다.

Set-Cookie: sessionid=a37046e1944553ee8dc2af0bc5c483fc; Path=/

그러니 위의 sessionid도 같이 cookie로 저장해야한다. 위와 동일하게 -c 옵션을 줘서 sessionid도 저장하게한다.

# curl -b cookie.txt http://xxx.xxx.xxx/login/ -d "username={ID}&password={PW}&csrfmiddlewaretoken=d7f6f683188d35958b0f453f6849a8d7" -c cookie.txt

cookie.txt파일을 열어보면 csrftoken값과 sessionid가 같이 저장 됨을 확인할 수 있다.

# Netscape HTTP Cookie File
# http://curl.haxx.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

xxx.xxx.xxx FALSE   /   FALSE   1499676993  csrftoken   97154f650b5e4e5a63fbec267072cc38
xxx.xxx.xxx FALSE   /   FALSE   0   sessionid   a37046e1944553ee8dc2af0bc5c483fc

이제 위 sessionid cookie를 이용하여 /bom/register/ page에 접근하면 된다.

# curl -b cookie.txt http://xxx.xxx.xxx/bom/register/

이제 위의 response가 200인지만 확인하면 된다.

TIP

firefox나 chrome에는 개발자 도구(F12)등이 포함되어 있다. 이곳에서 request, response의 header/body값과 cookie값등을 모두 확인할 수 있다.

이를 이용하면 실제 브라우져와 동일하게 http request를 요청하게 처리할 수 있을것이다. 위의 경우 curl로 진행했지만, python httplib 도 header와 body, cookie을 동일하게 처리하면 된다.

Script

위 내용을 토대로 http://xxx.xxx.xxx 사이트의 로그인 시도를 주기적으로 시도하여 문제 발생시(status code 400 이상일 경우) was와 mysql을 재시작하는 스크립트를 작성하였다. 해당 스크립트는 일반 사용자 계정으로 crontab에 등록되어 5분간격으로 실행된다.

crontab의 아래와 같이 오분마다 스크립트를 실행하도록 추가하였다.

$ crontab -e
*/5 * * * * ~/check_login.sh 2>&1

check_login.sh 스크립트 내용은 아래와 같다.

#!/bin/bash

USERID="sungmin" <- 사이트의 로그인 아이디를 입력
USERPW="sungmin" <- 사이트의 로그인 패스워드를 입력
DATE=$(date +%m%d%H%M)
LOG="~/log/check_login_${DATE}.log"

curl -c cookie.txt http://xxx.xxx.xxx/login/ -s > /dev/null

TOKEN=$(grep csrftoken cookie.txt | awk '{print $NF}')

curl -b cookie.txt -d "csrfmiddlewaretoken=${TOKEN}&username=${USERID}&password=${USERPW}" http://xxx.xxx.xxx/login/ -c cookie.txt

RESULT=$(curl -b cookie.txt http://xxx.xxx.xxx/bom/register/ -I -s | head -n1 | awk '{print $2}')
echo ${RESULT}

if [ ${RESULT} -gt 399 ]; then <- status code 결과 값이 400 이상일 경우 아래 명령을 실행
        echo "## Web Status code" > $LOG
        echo ${RESULT} >> $LOG
        echo "## Memory Status" >> $LOG
        free -m >> $LOG
        echo "## Process Check" >> $LOG
        ps -eo pid,rsz,vsz,cmd | grep python | grep -v grep >> $LOG
        echo "## Load average" >> $LOG
        w | head -n1 >> $LOG
        echo "Nginx & WAS & MySQL Restart" >> $LOG
        sudo /etc/init.d/nginx restart >> $LOG 2>&1
        sleep 1
        sudo /etc/init.d/mysql restart >> $LOG 2>&1
        sleep 1
        sudo -u sungmin ~/script/runserver restart >> $LOG 2>&1
        sleep 1
        echo ${RESULT} >> $LOG 2>&1
        mail -s "Server Error & Process Restart" sungmin@xxx.xxx < $LOG
fi

log는 문제 발생 시간에 mysql과 was가 재시작되며 그 전의 메모리 사용률, was 프로세스의 메모리 사용률과 갯수, load average를 log 파일로 기록한다.(로그파일 위치 : ~/check_login/log/)

아래는 mysql을 종료하고 스크립트가 구동되어진 후 로그를 출력. 이후 서비스 정상 확인.

$ cat check_login.log
## Memory Status
             total       used       free     shared    buffers     cached
Mem:          1982        399       1583          0         23        251
-/+ buffers/cache:        124       1857
Swap:          952          0        952
## Process Check
 4277 24672  87860 /usr/bin/python manage.py runfcgi host=127.0.0.1 port=8080
 4278 23888  87860 /usr/bin/python manage.py runfcgi host=127.0.0.1 port=8080
 4279 23908  87860 /usr/bin/python manage.py runfcgi host=127.0.0.1 port=8080
 4280 23908  87860 /usr/bin/python manage.py runfcgi host=127.0.0.1 port=8080
 4281 46904 158356 /usr/bin/python manage.py runfcgi host=127.0.0.1 port=8080
 4282 23908  87860 /usr/bin/python manage.py runfcgi host=127.0.0.1 port=8080
## Load average
 02:52:05 up 5 min,  2 users,  load average: 0.10, 0.12, 0.06
## Nginx & WAS & MySQL Restart
sudo: unable to resolve host test
Restarting nginx: nginx.
sudo: unable to resolve host test
Stopping MySQL database server: mysqld.
Starting MySQL database server: mysqld ..
Checking for tables which need an upgrade, are corrupt or were
not closed cleanly..
Stop django server : .Done
Start django server : Done.