DVWA의 세 번째 실습 대상인 CSRF다. 이 입력 폼은 비밀번호를 변경하는 시스템이며 이를 로그인한 사용자가 모르게 사용하여 비밀번호를 원하는 비밀번호로 변경하는 공격을 실습해볼 수 있다.
CSRF?
CSRF, Cross-Site Request Forgery는 사이트 간 요청 위조 공격으로 사용자가 자신의 의지와는 무관하게 공격자가 원하는 행위를 수행하게 된다는 것이 핵심이다. 이는 서버 애플리케이션이 어떤 요청을 보내는 사용자가 본인이 맞는지 확인하지 않고 사용자의 브라우저에 저장된 세션이나 쿠키를 신뢰하는 것을 악용한 공격으로 예를 들어 사용자가 특정 웹사이트에 로그인한 상태에서 정상적인 링크로 위장된 비밀번호 초기화 링크를 클릭하게 되면 서버에서는 정상적인 사용자(웹사이트에 로그인되어 세션이 유지되고 있음)가 비밀번호 초기화를 수행했다고 판단하여 해당 사용자의 비밀번호를 초기화시키는 것이다(출처).
어쨌든 해당 링크를 클릭하여 동작을 수행하게 되는 것은 로그인되어있는 인증된 사용자기 때문에 웹 서버 측에서는 이 요청이 공격자가 보낸 링크를 클릭해서 잘못 수행된 것인지 아니면 사용자가 정말 원해서 수행된 것인지 구분할 방법은 없다. 특이한 점은 여기서 사용자가 수행하게 될 요청은 공격자에게 어떤 응답이 전송되는 것이 아니기 때문에 사용자의 상태를 변화(state-changing)시키는 요청(비밀번호 초기화, 이메일 변경 등)을 수행하는 경우가 대부분이다. 그래야 나중에 취약해진 사용자의 계정에 공격자가 침투할 수 있기 때문이다.
그래서 CSRF 공격을 위해서는 다음처럼 3가지 요소가 필요하다.
-
사용자 상태에 유의미한 변화를 이끌어낼 수 있는 행위가 필요하다.
-
사용자 인증이 쿠키에 담긴 세션값을 이용해 진행되어야 한다.
-
공격자가 모르는, 입력할 수 없는 파라미터가 요청에 담겨선 안된다.
첫 번째 조건은 어쨌든 CSRF 공격으로 공격자가 사용자의 정보를 변경하거나 뭔가 바꿀 수 있는 기능이 필요하다는 것이고 두 번째 조건과 세 번째 조건을 살펴볼 필요가 있다. 일단 웹사이트에 대한 요청을 서버 측에서 진짜 사용자의 요청인지 아닌지 헷갈리려면 PHPSESSID처럼 브라우저의 쿠키에 저장된 세션 ID를 바탕으로 사용자를 구분해야 한다. 그래야 서버 측에서 이 세션 ID와 함께 전송된 공격자의 요청도 일반 사용자의 요청으로 받아들일 수 있기 때문이다. 하지만 CAPTCHA나 이전 비밀번호, 또는 특수 보안 문자 등 공격자가 예측할 수 없는 값이 요청에 포함되어야 한다면 공격자는 CSRF 공격을 준비하는 시점에서 이를 알아낼 수 없기 때문에 CSRF 공격을 수행할 수 없다. 그래서 위의 세 가지 조건이 모두 만족되어야 하는 것이다(PortSwigger 문서).
만약 XSS나 다른 업로드에 의해 이 CSRF 코드가 여러 사용자들이 접속할 수 있는 곳에 삽입되면(Stored CSRF) 공격의 영향은 더욱 커지게 된다. 예를 들어 비밀번호를 초기화시키는 링크가 웹사이트에 삽입된 <img> 태그의 src로 지정되어있다면 이 웹사이트에 접속한 모든 로그인된 사용자들은 비밀번호가 초기화될 가능성이 있다. 이 뿐만이 아니어도 사회공학적 기법을 사용해서 그럴듯한 피싱 이메일을 만들어서 보낸다면 해킹메일 교육을 제대로 받지 않은 사용자는 CSRF 공격에 노출될 수 있을 것이다.
그런데 이렇게 악성 링크를 삽입하여 사용자의 상태 변화를 꾀하는 공격은 파라미터가 URL에 포함되는 GET 방식에서만 가능한데 만약 해당 요청이 POST 방식을 사용한다면 어떻게 할 수 있을까? 이 경우 공격자가 미리 만들어준 사이트로 이동하거나 html 태그를 지원하는 경우 직접 form을 만들어서 해당 파라미터를 가진 input으로 요청할 수 있을 것이다(OWASP 문서).
이런 공격을 방지하기 위해 서비스를 요청하고 있는 사용자가 서버의 응답을 직접 받은 진짜 사용자인지 확인할 수 있는 CSRF Token, 웹 브라우저가 다른 사이트에서 온 요청에는 제한적(<a>, <link>, GET 만)으로 쿠키를 삽입하는 SameSite Cookie 등 여러 기법이 등장하였다(드림핵). 요즘은 다른 도메인에서 발생한 요청을 허용하지 않는 CORS(Cross-Origin Resource Sharing), SOP(Same-Origin Policy) 정책이 적용되기 때문에 이 공격이 발생할 가능성은 낮다.
Security Level: low
이 단계에서는 다음과 같은 요청이 전송된다.
GET /dvwa/vulnerabilities/csrf/?password_new=new_password&password_conf=new_password&Change=Change HTTP/1.1
Host: 192.168.56.103
Upgrade-Insecure-Requests: 1
DNT: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 Edg/87.0.664.66
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://192.168.56.103/dvwa/vulnerabilities/csrf/
Accept-Encoding: gzip, deflate
Accept-Language: ko,en;q=0.9,en-US;q=0.8
Cookie: security=low; security_level=2; PHPSESSID=602e5041e8dae819a9f2458eb7cbc165
Connection: close
GET 방식으로 요청되어 새로운 패스워드와 패스워드 확인 값이 모두 노출되는 것을 볼 수 있다. CSRF 공격에 대한 별다른 인증 방식은 사용하지 않고 있는 것을 볼 수 있다. 그리고 PHPSESSID 쿠키를 이용해 세션 ID를 보관하고 있다. 이 단계에서는 다음과 같은 소스 코드가 적용된다.
<?php
if( isset( $_GET[ 'Change' ] ) ) {
// Get input
$pass_new = $_GET[ 'password_new' ];
$pass_conf = $_GET[ 'password_conf' ];
// Do the passwords match?
if( $pass_new == $pass_conf ) {
// They do!
$pass_new = mysql_real_escape_string( $pass_new );
$pass_new = md5( $pass_new );
// Update the database
$insert = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . dvwaCurrentUser() . "';";
$result = mysql_query( $insert ) or die( '<pre>' . mysql_error() . '</pre>' );
// Feedback for the user
echo "<pre>Password Changed.</pre>";
}
else {
// Issue with passwords matching
echo "<pre>Passwords did not match.</pre>";
}
mysql_close();
}
?>
이번 실습에서는 Low 단계부터 mysql_real_escape_string()을 활용하여 SQL Injection을 방지하고 있다. 그리고 입력한 새로운 비밀번호와 새로운 비밀번호 확인값이 동일한지 검증하여 md5() 해시 후 데이터베이스에 저장하는 것을 볼 수 있다. 지금은 SQL 쿼리의 취약점을 분석할 때가 아니니 HTTP Request 내용을 바탕으로 어떻게 CSRF 공격 페이로드를 생성할 수 있을지 생각해봐야 할 것이다.
Security Level: medium
이 단계에서는 이전 단계와 동일한 요청이 전송된다.
이 단계에서는 다음과 같은 소스 코드가 적용된다.
<?php
if( isset( $_GET[ 'Change' ] ) ) {
// Checks to see where the request came from
if( eregi( $_SERVER[ 'SERVER_NAME' ], $_SERVER[ 'HTTP_REFERER' ] ) ) {
// Get input
$pass_new = $_GET[ 'password_new' ];
$pass_conf = $_GET[ 'password_conf' ];
// Do the passwords match?
if( $pass_new == $pass_conf ) {
// They do!
$pass_new = mysql_real_escape_string( $pass_new );
$pass_new = md5( $pass_new );
// Update the database
$insert = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . dvwaCurrentUser() . "';";
$result = mysql_query( $insert ) or die( '<pre>' . mysql_error() . '</pre>' );
// Feedback for the user
echo "<pre>Password Changed.</pre>";
}
else {
// Issue with passwords matching
echo "<pre>Passwords did not match.</pre>";
}
}
else {
// Didn't come from a trusted source
echo "<pre>That request didn't look correct.</pre>";
}
mysql_close();
}
?>
이전 단계와 같은 비밀번호 확인 및 업데이트 로직이 구현되어 있는데 이를 감싸고 있는 로직이 하나 더 추가되었다. eregi() 함수를 사용하여 $_SERVER 배열의 SERVER_NAME, HTTP_REFERER 변수를 비교하고 있는데 이는 무슨 구문일까?
이 함수는 deprecate되어 preg_match()로 대체된 함수지만 기본적으로 문자열에 대해 정규표현식 매칭을 수행하는 함수다. 첫 번째 매개변수로 정규식 패턴, 두 번째 매개변수로 비교 대상 문자열이 전달되었는데 정규식 패턴이 $_SERVER['SERVER_NAME'] 문자열이기 때문에 이 문자열이 $_SERVER['HTTP_REFERER']에 포함되어 있는지 검사하는 역할이다. 그렇다면 이 $_SERVER 변수는 무엇일까? 이는 서버 측 환경변수들을 저장하고 있다.
매개변수, 서버 주소, 게이트웨이 주소 등 여러 가지 항목이 있지만 여기에서 다루고 있는 SERVER_NAME과 HTTP_REFERER 항목을 찾아보면 다음과 같은 의미를 가진다.
...
'SERVER_NAME'
The name of the server host under which the current script is executing. If the script is running on a virtual host, this will be the value defined for that virtual host.
...
'HTTP_REFERER'
The address of the page (if any) which referred the user agent to the current page. This is set by the user agent. Not all user agents will set this, and some provide the ability to modify HTTP_REFERER as a feature. In short, it cannot really be trusted.
...
즉 $_SERVER['SERVER_NAME']은 현재 php 스크립트가 실행되고 있는 호스트의 이름, 여기서는 이 DVWA가 동작하고 있는 도메인인 "192.168.56.103"을 의미하며 $_SERVER['HTTP_REFERER']는 현재 페이지로 이동하기 전 브라우저가 있던 사이트의 주소(어디에서 이 사이트를 접속했는지에 따라 변경)를 의미한다. 이를 eregi() 함수로 매칭 시키는 것은 이 실습에서 진행하고 있는 비밀번호 변경 요청이 현재 DVWA 사이트 내부에서 전송된 요청인지 확인하는 것이다. 만약 공격자가 악의적으로 생성한 CSRF 링크를 메일로 받거나 사이트 외부에서 클릭했다면 Referer는 서버 측 주소가 아닌 다른 곳일 테니 eregi() 매칭이 실패하여 CSRF 공격을 탐지하는 것이다.
그렇다면 이를 어떻게 우회할 수 있을까? 실제로 요청을 동일한 웹사이트 내부에서 수행(사용자가 게시판 글을 읽거나 메일을 읽는다는 가정 하에)하거나 Referer 헤더를 위조하는 방법이 있을 것이다. 어떻게든 HTTP Request의 요청은 공격자가 마음대로 조작할 수 있기 때문에 이를 전적으로 신뢰해서는 안된다.
Security Level: high
이 단계에서는 다음과 같은 요청이 전송된다.
GET /dvwa/vulnerabilities/csrf/?password_new=high_password&password_conf=high_password&Change=Change&user_token=2990919d65750d5c4f2159c92798290b HTTP/1.1
Host: 192.168.56.103
Upgrade-Insecure-Requests: 1
DNT: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 Edg/87.0.664.66
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://192.168.56.103/dvwa/vulnerabilities/csrf/
Accept-Encoding: gzip, deflate
Accept-Language: ko,en;q=0.9,en-US;q=0.8
Cookie: security=high; security_level=2; PHPSESSID=602e5041e8dae819a9f2458eb7cbc165
Connection: close
이전 단계들과 달라진 것은 CSRF 토큰이 파라미터로 포함된 것이다. 이전에 Brute Force 실습에서 봤던 것처럼 숨겨진 input에 user_token 값이 숨어 있으며 GET 요청 시 파라미터로 같이 전달함으로써 사용자가 직접 비밀번호 변경 요청을 보낸 것인지 인증하는 것이다. 이 단계에서는 다음과 같은 소스 코드가 적용된다.
<?php
if( isset( $_GET[ 'Change' ] ) ) {
// Check Anti-CSRF token
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );
// Get input
$pass_new = $_GET[ 'password_new' ];
$pass_conf = $_GET[ 'password_conf' ];
// Do the passwords match?
if( $pass_new == $pass_conf ) {
// They do!
$pass_new = mysql_real_escape_string( $pass_new );
$pass_new = md5( $pass_new );
// Update the database
$insert = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . dvwaCurrentUser() . "';";
$result = mysql_query( $insert ) or die( '<pre>' . mysql_error() . '</pre>' );
// Feedback for the user
echo "<pre>Password Changed.</pre>";
}
else {
// Issue with passwords matching
echo "<pre>Passwords did not match.</pre>";
}
mysql_close();
}
// Generate Anti-CSRF token
generateSessionToken();
?>
Medium 단계에서 사용한 Referer 인증을 사용하지 않고 CSRF 토큰으로만 인증하고 있는 모습을 볼 수 있다. Brute Force 실습 때처럼 파이썬 스크립트를 활용하면 CSRF 토큰을 파싱해서 인증할 수 있을 텐데 이를 위해서는 단순 URL 클릭만으로는 불가능하고 공격자 서버에 페이로드를 작성해서 실습해봐야 할 것이다.
Security Level: impossible
이 단계에서는 다음과 같은 폼으로 구성된다.
사용자 패스워드를 변경하기 이전에 현재 비밀번호를 입력하도록 요구하고 있으며 이는 공격자가 사전에 예측할 수 없는 값이기 때문에 위의 3가지 조건에 위배되어 CSRF 공격이 불가능하다. 이 단계에서는 다음과 같은 요청이 전송된다.
GET /dvwa/vulnerabilities/csrf/?password_current=high_password&password_new=password&password_conf=password&Change=Change&user_token=16df21f6e54b39d8a7a9ddd58074261f HTTP/1.1
Host: 192.168.56.103
Upgrade-Insecure-Requests: 1
DNT: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 Edg/87.0.664.66
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://192.168.56.103/dvwa/vulnerabilities/csrf/?password_current=new_password&password_new=password&password_conf=password&Change=Change&user_token=3e5e0b8d2c18d2c7c145daaa97e1112f
Accept-Encoding: gzip, deflate
Accept-Language: ko,en;q=0.9,en-US;q=0.8
Cookie: security=impossible; security_level=2; PHPSESSID=602e5041e8dae819a9f2458eb7cbc165
Connection: close
여전히 GET 메소드로 비밀번호를 파라미터로 전달하고 있기 때문에 URL에 전부 노출된다는 단점이 있다. 이 단계에서는 다음과 같은 소스 코드가 적용된다.
<?php
if( isset( $_GET[ 'Change' ] ) ) {
// Check Anti-CSRF token
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );
// Get input
$pass_curr = $_GET[ 'password_current' ];
$pass_new = $_GET[ 'password_new' ];
$pass_conf = $_GET[ 'password_conf' ];
// Sanitise current password input
$pass_curr = stripslashes( $pass_curr );
$pass_curr = mysql_real_escape_string( $pass_curr );
$pass_curr = md5( $pass_curr );
// Check that the current password is correct
$data = $db->prepare( 'SELECT password FROM users WHERE user = (:user) AND password = (:password) LIMIT 1;' );
$data->bindParam( ':user', dvwaCurrentUser(), PDO::PARAM_STR );
$data->bindParam( ':password', $pass_curr, PDO::PARAM_STR );
$data->execute();
// Do both new passwords match and does the current password match the user?
if( ( $pass_new == $pass_conf ) && ( $data->rowCount() == 1 ) ) {
// It does!
$pass_new = stripslashes( $pass_new );
$pass_new = mysql_real_escape_string( $pass_new );
$pass_new = md5( $pass_new );
// Update database with new password
$data = $db->prepare( 'UPDATE users SET password = (:password) WHERE user = (:user);' );
$data->bindParam( ':password', $pass_new, PDO::PARAM_STR );
$data->bindParam( ':user', dvwaCurrentUser(), PDO::PARAM_STR );
$data->execute();
// Feedback for the user
echo "<pre>Password Changed.</pre>";
}
else {
// Issue with passwords matching
echo "<pre>Passwords did not match or current password incorrect.</pre>";
}
}
// Generate Anti-CSRF token
generateSessionToken();
?>
Medium 단계에서 사용한 Referer 검사는 이번에도 적용되지 않았다. 아무래도 쉽게 우회가 가능하다 보니 그런 것 같은데 대신 CSRF 토큰과 공격자가 알 수 없는 새로운 파라미터를 이용하여 CSRF 공격을 방지하고 있다. Brute Force 실습의 Impossible 단계와 마찬가지로 PDO를 사용하여 mysql 쿼리를 수행하고 있다.
언뜻 보면 특별한 CSRF 방지 대책이 없어보이지만 사용자의 현재 패스워드를 알 수 없는 공격자는 이를 요구하는 비밀번호 변경 서비스에 대한 CSRF 공격을 수행할 수 없다. 특히 요즘 대부분의 웹서비스에서 사용자 계정의 비밀번호를 바꿀 때 원래 비밀번호를 입력하도록 하는 추가적인 인증 과정을 거치기 때문에 CSRF 공격이 많이 사그라든 원인이라 할 수 있을 것이다.
'프로젝트 > DVWA 실습' 카테고리의 다른 글
DVWA 실습 #4-2 - CSRF(medium) (0) | 2021.01.07 |
---|---|
DVWA 실습 #4-1 - CSRF(low) (0) | 2021.01.06 |
DVWA 실습 #3-3 - Command Injection(high) (0) | 2021.01.06 |
DVWA 실습 #3-2 - Command Injection(medium) (0) | 2021.01.05 |
DVWA 실습 #3-1 - Command Injection(low) (0) | 2021.01.05 |