脆弱性が報告されてから修正が完了するまでに、Webアプリケーション開発者(またはテスター)ができることをセキュリティテストを中心に書く。
- 再現手順の確立
- 原因究明
- 対策の実行
- 修正の確認
- セキュリティテスト
セキュリティテストってどうやればいいの?とか、セキュリティベンダの脆弱性診断高すぎ内製化したる!とか、そんな人たちの参考になればいいなあ。
※本記事のサンプルコードは C# + Entity Framework だが、プロセスは他の言語やフレームワークでも通用する。
1.再現手順の確立
※注意:再現行為を本番環境でやるのは危険すぎるので、検証環境でやるのがよい
脆弱性が報告されたら、通常のバグ修正と同じように、まず脆弱性を再現させる。(TDD風に言うなら、レッドになるテストを作る)
SQLインジェクションの場合、主にHTTPリクエスト(URL、ヘッダ、本文など)が再現手順になる。あらかじめ Web サイトへのログインが必要な場合は、それも再現手順に含めておく。
再現手順を確立しておくことで、修正後に再現しなくなる(テストがグリーンになる)=修正できたことを確認できるようになる。
また、この時点で脆弱性スキャナを実行して脆弱性が検出されることを確認しておくのもよい。理由は後述の 脆弱性スキャン に記載。
2.原因究明
SQL インジェクションの原因は、たとえば次のようなケースが考えられる。
- 自分たちが書いたコードに問題がある場合
- ミドルウェアやライブラリ、プラグイン等に脆弱性があった場合
- 脆弱なテストプログラムやサンプルプログラムが残ったままになっていた場合
原因がわかったら、報告を受けた箇所だけではなくほかにも同様の問題がないかも確認する。
今回は自分たちが書いたコードに問題がある場合を想定して話を進める。
3.対策の実行
SQLインジェクションの対策について詳しく知りたい場合は、IPA が公開している 安全なSQLの呼び出し方 を参照。
たとえばこんなコードを
var sql = "SELECT Id, Author, Title, PostedAt, Text From Posts";
sql += $" Where Author like '%{author}%'";
return await _context.Posts.FromSqlRaw(sql).ToArrayAsync();
こんな感じに修正する
return await _context.Posts.Where(p => p.Author.Contains(author)).ToArrayAsync();
4.修正の確認
1.再現手順の確立 で確立した脆弱性の再現方法を修正箇所に試し、問題が再現しないことを確認する。
これで対策完了!ではない。
5.セキュリティテスト
4.修正の確認は「正しくできた」ことを確認するテスト。対して「正しくできていない」ことを発見するのがセキュリティテストの役割になる。「正しくできていない」とは、間違った対策や中途半端な対策のこと。
セキュリティテストは、通常のテストと同様、コストと網羅性のバランスが必要。修正完了までコードの変更とテストが何度も繰り返されることも多いので、フィードバックの早さも重要。すべてを満たすテスト手法は多分ないので、複数の手法を組み合わせるのが良い。
SQLインジェクションの問題は、コードを見ればある程度問題の有無を判断できることと、自動テストを書きやすそうなことから、次の3つの手法を採用することにする。
- コードインスペクション
- ○コスト安:手っ取り早く実行できる
- ○フィードバック超早:コードが見れればできる
- ×属人性高:セキュアコーディングのスキルが必要
- ファジング
- ○フィードバック早:自動化しやすく、繰り返し実行できる
- △コスト中:入力データや自動テストを作る必要がある
- △属人性中:適切なテストデータを作る・選ぶスキルが必要
- 脆弱性スキャン
- ○属人性低:独立性が高く、かつ安定した結果が得られる
- ×フィードバック遅:デプロイが必要
- ×コスト高:ツールによってはお金がかかる。学習コストもあるかも。
プロジェクトによっては下記のような選択肢もあるかもしれない。
- 静的コード解析ツール(SAST)を使う(良い精度のツールがあるなら)
- sqlmap のようなツールを使って疑似攻撃を試みる(セキュリティエンジニアがいるなら)
- セキュリティベンダに診断を依頼する(超重要なシステムで、かつ、お金と時間があるなら)
コードインスペクション
コードインスペクションは静的なホワイトボックステストの1つ。あるルールに基づいて人(または機械)がコードを精査し、欠陥を発見する手法。
コードさえ見れれば、デプロイどころかコンパイラすらしなくてもテストできるため、実行コストが低くフィードバックがとても早いという利点がある。ただし精度はテスターのスキルに依存する。
コードインスペクションではセキュリティの問題を発見することにのみ集中する。SQL インジェクション対策のインスペクションであれば、「パラメータを文字列操作してSQL文を作成していないこと」のようなルールをあらかじめ定めたうえで精査する。
ルールは言語やフレームワークによって異なる点に注意。C# + Entity Framework の場合、下記のようなルールになる。
var sql = "SELECT * from Posts";
var sql = "SELECT * from Posts";
sql += " WHERE author = 'Tanaka Ichiro'";
var sql = "SELECT Id, Author, Title, PostedAt, Text From Posts";
sql += " Where Author = {0}";
_context.Posts.FromSqlRaw(sql, author)...
dbContext.Posts.Where(p => p.Author == author)...
var sql = "SELECT * from Posts";
sql += " WHERE author = '" + author + "'";
var sql = "SELECT * from Posts";
sql += " WHERE author = '{AUTHOR}'");
sql = sql.Replace("{AUTHOR}", author);
※例では SELECT のみ挙げたが INSERT, UPDATE, DELETE 等ほかのSQL文のルールも必要
コーディング規約がない場合はいろんなSQL発行パターンがわいてくるので、判断基準を作りながらテストするか、いっそ標準を定めて標準以外の書き方はNGとしてしまってもよいかもしれない。
コードインスペクションのアウトプットは、テスト対象となったファイルとその結果の一覧になる。(例)
ファイル名 | SQL発行の有無 | メソッド名 | パラメータ有無 | SQL発行方法 | OK/NG |
---|---|---|---|---|---|
Hoge.cs | 無し | - | - | - | OK |
Foo.cs | 有り | Bar | 無し | - | OK |
Foo.cs | 有り | Nar | 有り | LINQ to SQL | OK |
Moge.cs | 有り | Soge | 有り | Parametered Query | OK |
Moge.cs | 有り | Doge | 有り | 文字列連結 | NG |
この結果を受けて、例えば次のような判断ができる。
- NG がなくなるまで修正と再テストを繰り返す
- (コードの修正が困難すぎて)NGや判断不可な箇所を
- 以降のテストでカバーする
- 熟練セキュアコーダーにインスペクションしてもらう(人がいれば)
- この結果にかかわらず、以降のテストを全箇所実施する
どうするかは、テストの独立性やコストパフォーマンス等のバランスを見て決めればよい。
ファジング
ファジングは動的なブラックボックステストの1つで、「変なデータ」を入力しまくって欠陥を見つける手法。詳しくは IPA の 脆弱性対策:ファジング を参照。
ファジングはWebサイトに対して実行することもできるし、SQLを発行している関数・メソッドに対しても実行できる。
テストは小さい単位でやるほうがコストパフォーマンスがよくフィードバックも早いので、可能なら関数・メソッドに対してやるのがお勧め。もちろん Web サイトにやってもよい。
「変なデータ」はランダムに作っても良いが、今回は fuzzdb で公開されている SQL インジェクションを発見するための入力パターン集を使う。
ユニットテストフレームワークで実行
関数・メソッドに対してテストする場合、ユニットテストフレームワークを使うのが楽。(ユニットテストか?という疑問は気にしないことにする)
以下は C# + ASP.NET Core + SQL Server + xUnit.net の例
こんな脆弱なコードに対して
public class BlogPostsController : Controller
{
private readonly BlogDbContext _context;
public BlogPostsController(BlogDbContext context)
{
_context = context;
}
public async Task<BlogPost[]> SearchByAuthor(string author)
{
// 危険なコード!
var sql = "SELECT Id, Author, Title, PostedAt, Text From Posts";
sql += $" Where Author like '%{author}%'";
return await _context.Posts.FromSqlRaw(sql).ToArrayAsync();
}
// 省略...
こんなテストコードを書く
public class UnitTest1
{
// fuzz.txt の1行を1テストケースとして定義
// ※後述の Parameters プロパティを参照
[Theory]
[MemberData(nameof(Parameters))]
public async Task SqlInjectionTest(string param)
{
// * Arrange
using (var db = new BlogDbContext())
{
// テーブルを空にしておく
db.Posts.RemoveRange(db.Posts.ToArray());
// SQL が正しく実行されれば、この Entity が帰ってくるはず
db.Posts.Add(new BlogPost() { Author = param, Title = "test" });
// こっちは帰ってこないはず
db.Posts.Add(new BlogPost() { Author = "Dummy", Title = "Dummy" });
db.SaveChanges();
}
using (var db = new BlogDbContext())
{
var controller = new BlogPostsController(db);
var sw = Stopwatch.StartNew();
// * Act
var result = await controller.QueryPosts(param);
// * Assert
// 実行に5秒以上かかったら Blind な SQL Injection が成功してるのでNG
Assert.True(sw.ElapsedMilliseconds < 5000, "Blind SQL Injection");
// 正しいSQL文が発行されていれば1件の結果が返ってくるはず
Assert.Equal(param, result.Single().Author);
}
}
// SqlInjectionTest で使う入力データ列
public static IEnumerable<object[]> Parameters
{
get
{
var f = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "fuzz.txt");
var r = File.ReadAllLines(f)
.Where(l => !string.IsNullOrWhiteSpace(l))
.Select(l => new object[] { l })
.ToArray();
return r;
}
}
}
ユニットテストの入力データに使う fuzz.txt はこんな感じ。
; --
'; --
'); --
'; exec master..xp_cmdshell 'ping 10.10.1.2'--
' grant connect to name; grant resource to name; --
' or 1=1 --
' union (select @@version) --
' union (select NULL, (select @@version)) --
' union (select NULL, NULL, (select @@version)) --
' union (select NULL, NULL, NULL, (select @@version)) --
' union (select NULL, NULL, NULL, NULL, (select @@version)) --
' union (select NULL, NULL, NULL, NULL, NULL, (select @@version)) --
'; if not(substring((select @@version),25,1) <> 0) waitfor delay '0:0:5' --
'; if not(substring((select @@version),25,1) <> 5) waitfor delay '0:0:5' --
'; if not(substring((select @@version),25,1) <> 8) waitfor delay '0:0:5' --
'; if not(substring((select @@version),24,1) <> 1) waitfor delay '0:0:5' --
'; if not(select system_user) <> 'sa' waitfor delay '0:0:5' --
'; if is_srvrolemember('sysadmin') > 0 waitfor delay '0:0:5' --
'; if not((select serverproperty('isintegratedsecurityonly')) <> 1) waitfor delay '0:0:5' --
'; if not((select serverproperty('isintegratedsecurityonly')) <> 0) waitfor delay '0:0:5' --
上記の例では BlogPostsController クラスの SearchByAuthor メソッドに対してファジングしているが、同様に他のテスト対象のメソッド・パラメータに対してもテストを作り、実行すればよい。SQL 文を正しく作れている(脆弱性が無い)ならすべてのテストがグリーンになるはず。
Webサイトに対して実行
ユニットテストが困難な場合や、ミドルウェア・フレームワークのバグも心配な場合は、稼働している Web サイトを対象にファジングする。ファジングの方法は IPA が公開している ファジング実践資料 第2版 を参照。
ただし、ファジングを Web サイトにするのは、ユニットテストの場合と比較すると下記の点で不利。
- デプロイしないとテストできない
- テスト環境が必要(本番環境を使うのは超危険!)
- フィードバックが遅い
- ファジングツールが必要(自前でスクリプトを書いてもいいけど)
脆弱性スキャン
最後に独立性の高いテストとして、脆弱性スキャンを実行する。上2つのテストが成功していればこのテストも成功する可能性が高いが、万が一の抜け漏れを見つけてくれるかもしれない。
注意点は、スキャナの性能や設定値によっては脆弱性を検出できないことがあること。そのため、次の2点を確認するのがよい。
- 修正前のWebサイトをスキャンして脆弱性が報告されること
- 修正後のWebサイトをスキャンして脆弱性が報告されないこと(1.と同じスキャン設定で)
下記は Nessus を使った例。Nessus の使い方は説明しない。
※Nessus を選んだ理由はたまたま手元にあったから、というだけで、Nessus を推奨しているわけではない。
修正前:CGI Generic SQL Injection という脆弱性が報告されている
修正後:SQL Injection とそれに関連する脆弱性の報告が消えている
このテストの成功をもって脆弱性対策を完了とする。ただし↓
退行テスト
脆弱性を修正したら機能が使えなくなった、では意味がないので忘れずに。
(セキュリティテストではないけど)
まとめ
SQLインジェクションが発見されてから修正完了まで、Webアプリケーション開発者(またはテスター)ができることの流れを書いた。
- 再現手順の確立:脆弱性の再現方法を確立する
- 原因究明:脆弱性の原因を特定する
- 対策の実行:原因に対応した方法で対策する
- 修正の確認:1で確立した再現方法を使い、問題が解消されたことを確認する。
- セキュリティテスト:コスト、網羅性、独立性、使えるリソース等を考えて、適切なテスト手法を選択する。
セキュリティテストの内製化の参考になれば。
参考
IPAが公開しているガイドライン。開発者以外(PM/POとかセキュリティ担当とか)にも役立つ情報あり。
Entity Framework 関連