Edited at

ウェブアプリでCSRF対策してみる(Part2)

More than 1 year has passed since last update.


導入

Part1からの続きです。

今回は、CSRF対策をしていないサイトを作成し、リンクをクリックしただけで、勝手にアカウント情報が書き換えられることを確認します。

そして、CSRF対策をすることで、第三者が勝手にアカウント情報を書き換えることができないことを確認し、CSRF対策の有効性を体験します。


環境構築

基本的に環境はPart1のものを利用します。

最終的な構成は、以下のようになります。

csrfTest

|
--- app.js
|
--- views
| |
| --- login.jade
| --- login_evil.jade
| ---layout.jade
| ---index.jade
| ---change_password.jade
|
--- routes
| |
| --- index.js
|
--- public
|
--- javascripts
|
---userData.json

なお、今後webサーバの再起動といった場合は、以下の処理の事をさすこととします。

Ctrl + C

$ npm ./bin/www


アカウント情報保存用ファイルを作成

「public/javascripts/userData,json」というファイルを新規に作成し、以下の内容を貼り付けてください。

今回はusernameがaaaとbbbの二つのアカウントを用意しています。


public/javascripts/userData.json

[

{
"username": "aaa",
"password": "test1"
},
{
"username": "bbb",
"password": "test2"
}
]


外部サーバ環境を構築

今回は外部のサーバも用意し、悪意のあるスクリプトが含まれるページを設置します。

外部のサーバの構築手順を記載します。

環境はubuntuを想定しており、npmとnodeはインストール済みであるとします。

$ mkdir test-server

$ cd test-erver
$ npm install http-server

// 以下のコード(evil_script.html)を貼り付けてください
$ vim evil_script.html

// 以下のコマンドでwebサーバが起動する
$ node ./node_modules/http-server/bin/http-server


evil_script.html

<!DOCTYPE html>

<html lang="ja">
<head>
<meta charset="UTF-8">
<title>post test</title>
<script src="http://code.jquery.com/jquery.js"></script>
<script type="text/javascript">
$.ajax({
type: "POST",
url: "http://localhost:3000/change_password",
data: {
"password": "hehehe",
},
xhrFields:{
withCredentials: true
},
});
</script>
</head>
<body>
</body>
</html>

evil_script.htmlの説明をします。

このファイルにアクセスすると、「http://localhost:3000/change_password」に対して、パスワードをPOSTします。

CSRF対策がなされていない場合は、ユーザの意図せずパスワードが変更されてしまいます。

ここでのミソは、xhrFieldsのwithCredentialsをtrueにしている部分です。

これにより、cookieの送信が可能になるので、セッションを維持した状態で、パスワード情報を送信することが可能になります。

サーバを起動させると、下図のような表示が出てくると思います。

今回の場合は、「http://192.168.0.12:8080」にアクセスすると、HTMLファイルを取得することができます。

Ubuntu__64_ビット__17_10.png


view関係のファイルを追加・編集

index.jadeは内容を上書きしてください。

change_password.jadeはファイルを新規作成してください。


views/index.jade

extends layout

block content
a(href="/login") Login(csrfToken)
br

a(href="/login_evil") Login evil(csrfToken無し)
br

a(href="http://192.168.0.12:8080/evil_script.html") Evil Script
br

if (user)
こんにちは #{user} さん
br
a(href="/change_password") Change Password
br
a(href="/logout") Logout



views/change_password.jade

extends layout

block content
.container
h1 Change Password Page
p こんにちは #{user} さん
p.lead パスワード変更できます(csrfTToken無し)
br
form(role='form', action="/change_password",method="post", style='max-width: 300px;')
.form-group
input.form-control(type='password', name="password", placeholder='Password')
button.btn.btn-default(type='submit') Submit
&nbsp;
a(href='/')
button.btn.btn-primary(type="button") Cancel



Passportによる認証機能を追加

今回はユーザ認証を行います。

passportというnpmファイブラリを使用して簡単にユーザ認証を実装することができます。

passportの使い方などは、こちらを参照してください。


app.js

var createError = require('http-errors');

var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');

// 以下追記===============================================
var passport = require('passport');
var LocalStrategy = require('passport-local').Strategy;
var session = require('express-session');
// ======================================================

var indexRouter = require('./routes/index');
var usersRouter = require('./routes/users');

var app = express();

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');

app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

// 以下追記=================================================
// セッションミドルウェア設定
app.use(session({ resave:false,saveUninitialized:false, secret: 'passport test' }));

app.use(passport.initialize());
app.use(passport.session());
var LocalStrategy = require('passport-local').Strategy;
passport.use(new LocalStrategy({
usernameField: 'username',
passwordField: 'password',
passReqToCallback: true,
session: false,
}, function (req, username, password, done) {
process.nextTick(async function () {
if (await isPasswordCheck(username, password)) {
console.log("login ok");
return done(null, username)
} else {
console.log("login error")
return done(null, false, { message: 'パスワードが正しくありません。' })
}
})
}));

passport.serializeUser(function (user, done) {
done(null, user);
});

passport.deserializeUser(function (user, done) {
done(null, user);
});

// 保存されたusername,passwordかどうかチェックする
async function isPasswordCheck(username, password){
const PROJECT_ROOT_PATH = process.cwd();
const data = await readFile(PROJECT_ROOT_PATH + "/public/javascripts/userData.json");

for(let datatemp of data){
if(datatemp["username"] === username && datatemp["password"] === password){
return true;
}
}
};

// jsonファイルを読み込んでJSオブジェクトに変換して返す
function readFile(filePath){
const fs = require("fs");
return new Promise((resolve, reject) =>{
let jsonData;
fs.readFile(filePath,"utf8",function(err,data){
if(err){throw err;}
resolve(JSON.parse(data));
});
});
};

// ここまで追記===============================================

app.use('/', indexRouter);
app.use('/users', usersRouter);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
next(createError(404));
});

// error handler
app.use(function(err, req, res, next) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};

// render the error page
res.status(err.status || 500);
res.render('error');
});

module.exports = app;



routes/index.js


var cookieParser = require('cookie-parser');
var csrf = require('csurf');
var bodyParser = require('body-parser');

var express = require('express');
var router = express.Router();

var csrfProtection = csrf({ cookie: true })
var parseForm = bodyParser.urlencoded({ extended: false })

var passport = require('passport'); // 追記

router.get('/', function(req, res, next) {
res.render('index', { user: req.user }); // 編集
});

router.get('/login', csrfProtection, function(req, res) {
// csrfToken付きでページを返す
res.render('login', { csrfToken: req.csrfToken() })
});

router.get('/login_evil', function(req, res) {
// csrfToken無しでページを返す
res.render('login_evil')
});

// 以下編集 passportによる認証を追加==================================================
router.post('/login', parseForm, csrfProtection, passport.authenticate('local', {
successRedirect: '/',
failureRedirect: '/login',
session: true}
));

router.get('/logout', function(req, res) {
req.logout();
res.redirect('/');
});

router.get('/change_password', function(req, res) {
if(!req.user){
res.redirect('/');
}
res.render('change_password',{user: req.user});
});

router.post('/change_password', async function(req, res) {
await changePssword(req.user, req.body.password);
res.redirect('/');
});

async function changePssword(username, password){
const PROJECT_ROOT_PATH = process.cwd();
const data = await readFile(PROJECT_ROOT_PATH + "/public/javascripts/userData.json");

for(let datatemp of data){
if(datatemp["username"] === username){
datatemp["password"] = password;
}
}
await writeFile(PROJECT_ROOT_PATH + "/public/javascripts/userData.json", data);
}

// jsonファイルを読み込んでJSオブジェクトに変換して返す
function readFile(filePath){
const fs = require("fs");
return new Promise((resolve, reject) =>{
let jsonData;
fs.readFile(filePath,"utf8",function(err,data){
if(err){throw err;}
resolve(JSON.parse(data));
});
});
};

// jsonファイルにJSオブジェクトを書き込む
function writeFile(filePath, data){
const fs = require("fs");
return new Promise((resolve, reject) => {
fs.writeFile(filePath, JSON.stringify(data, null, " "));
resolve();
});
};

// ================================================================================

module.exports = router;



CSRF脆弱性を利用してパスワードを勝手に変更

まずは、通常のパスワード変更手順を行います。

webサーバを再起動してください。

「localhost:3000」にアクセスして、「Login(csrfToken)」をクリックしてください。

次に、usernameに「aaa」を、passwordに「test1」をそれぞれ入力して、ログインしてください。(usernameに「bbb」をpasswordに「test2」でも構いません)

localhost_3000.png

上図のようになっていればOKです。

次に、Change Passwordをクリックして、新しいパスワードを入力し、submitボタンを押してください。

これで、パスワードは変更できます。

localhost_3000_change_password.png

次に、Logoutをクリックして、ログアウトしてください。

再度ログインする時は、新しく設定したパスワードでログインできることを確認してください。

最後に、ログインした状態でEvil Scriptをクリックしてください。

これは、外部サーバへのリンクになっており、外部サーバのページへ移動します。

何も表示されないのですが、ページが遷移した後、パスワード変更のリクエストが送信され、パスワードが勝手に変更されてしまいます。

post_test.png

上図は、外部サーバのページへ遷移した状態です。

「localhost:3000/change_password」にデータをPOST出来ているのがわかります。

再度ログアウトして、ログインを試みてください。

悪意のあるスクリプトによって、パスワードを「hehehe」に変更されているので、ログインできないと思います。


CSRF対策を実装

まず、先ほどパスワードが勝手に「hehehe」に変更されたので、元に戻します。

「public/javascripts/userData.json」のpasswordの部分を、「test1」に修正してください。

次にソースコードを修正します。

下記のコードを貼り付けてください。


routes/index.js


var cookieParser = require('cookie-parser');
var csrf = require('csurf');
var bodyParser = require('body-parser');

var express = require('express');
var router = express.Router();

var csrfProtection = csrf({ cookie: true })
var parseForm = bodyParser.urlencoded({ extended: false })

var passport = require('passport');

router.get('/', function(req, res, next) {
res.render('index', { user: req.user });
});

router.get('/login', csrfProtection, function(req, res) {
// csrfToken付きでページを返す
res.render('login', { csrfToken: req.csrfToken() })
});

router.get('/login_evil', function(req, res) {
// csrfToken無しでページを返す
res.render('login_evil')
});

router.post('/login', parseForm, csrfProtection, passport.authenticate('local', {
successRedirect: '/',
failureRedirect: '/login',
session: true}
));

router.get('/logout', function(req, res) {
req.logout();
res.redirect('/');
});

router.get('/change_password', csrfProtection, function(req, res) {
if(!req.user){
res.redirect('/');
}
res.render('change_password',{user: req.user, csrfToken: req.csrfToken()}); // 編集
});

router.post('/change_password', parseForm, csrfProtection, async function(req, res) { // 編集
await changePssword(req.user, req.body.password);
res.redirect('/');
});

async function changePssword(username, password){
const PROJECT_ROOT_PATH = process.cwd();
const data = await readFile(PROJECT_ROOT_PATH + "/public/javascripts/userData.json");

for(let datatemp of data){
if(datatemp["username"] === username){
datatemp["password"] = password;
}
}
await writeFile(PROJECT_ROOT_PATH + "/public/javascripts/userData.json", data);
}

// jsonファイルを読み込んでJSオブジェクトに変換して返す
function readFile(filePath){
const fs = require("fs");
return new Promise((resolve, reject) =>{
let jsonData;
fs.readFile(filePath,"utf8",function(err,data){
if(err){throw err;}
resolve(JSON.parse(data));
});
});
};

// jsonファイルにJSオブジェクトを書き込む
function writeFile(filePath, data){
const fs = require("fs");
return new Promise((resolve, reject) => {
fs.writeFile(filePath, JSON.stringify(data, null, " "));
resolve();
});
};

module.exports = router;



views/change_password.jade

extends layout

block content
.container
h1 Change Password Page
p こんにちは #{user} さん
p.lead パスワード変更できます(csrfTToken有り)
br
form(role='form', action="/change_password",method="post", style='max-width: 300px;')
.form-group
input.form-control(type='password', name="password", placeholder='Password')
button.btn.btn-default(type='submit') Submit
&nbsp;
a(href='/')
button.btn.btn-primary(type="button") Cancel
input(type='hidden' name='_csrf' value=csrfToken)


上記のコードについて簡単に解説します。

変更点をい以下に抜き出しました。

まず、「router.get('/change_password...」の部分で、jadeファイルにcsrfTokenを渡しています。

jade側では、渡されたcsrfTokenを埋め込んでhtmlを作成します。

次に、「router.post('/change_password'...」の部分で、csrfTokenのチェックを行います。

POSTリクエストにcsrfTokenが含まれなかった場合は、関数の中の処理は行われず、パスワードの変更もされせん。


routes/index.jade

...

router.get('/change_password', csrfProtection, function(req, res) {
if(!req.user){
res.redirect('/');
}
res.render('change_password',{user: req.user, csrfToken: req.csrfToken()}); // 編集
});

router.post('/change_password', parseForm, csrfProtection, async function(req, res) { // 編集
await changePssword(req.user, req.body.password);
res.redirect('/');
});
...



CSRF対策が動作しているのか確認

実際にCSRF対策が本当に機能しているのかを確認します。

まず、webサーバを再起動してください。

次に、「localhost:3000」にアクセスし、「Login(csrf)」をクリックしてください。

usernameにaaaを、passwordにtest1を入力し、submitボタンを押してください。

localhost_3000_login-4.png

次に、「Evil Script」をクリックしてください。

「public/javascripts/userData.json」を開いて、パスワードが変更されていないことを確認してください。

では、今回のPOSTリクエストでは、どのようなやり取りになっていたのかを確認しましょう。

post_test-2.png

上図のように今回はPOSTリクエストに対して403(Forbidden)が帰ってきていることがわかります。

しっかりとcsrfTokenがないPOSTリクエストははじいていますね。

ちなみに、CSRF対策をしていない場合は、POSTリクエストに対して、302(Found)が帰ってきていました。

最後に、正規の手順でパスワードを変更できるのかを確認してみてください。

方法を簡単に記載します。

ログイン状態でChange Passwordをクリックし、新しいパスワードを入力してsubmitボタンを押す


補足事項

ちなみに、外部サーバのページに移動した時も、cookieに_csrfが存在するので、この値をPOSTリクエストに含めて送信すればいいのでは、と思われた方もいらっしゃるかもしれません。

使用中の_Cookie_と_post_test.png

確かに、上記のように_csrfのクッキーは存在しますが、ドメインがlocalhostになっています。

異なるドメインのクッキーは参照できないという制約があるので、このクッキーを外部サーバのスクリプトから参照することは出来ません。


最後に

今回の2回の記事を通して、認証し、ログイン状態を維持するようなウェブアプリケーションではCSRF対策をしていないと、非常に危険だということがお判り頂けたかと思います。

Expressの場合は、csurfというnpmライブラリを使うことで、比較的簡単にCSRF対策を行うことが出来ます。

みなさんの参考になれば幸いです。