SSRFの脆弱性について セキュアコーディングの啓蒙 第3回

はじめに

こんにちは!

株式会社ユーザベース BtoB SaaS Product Team(以下 Product Team)の山田・度會です。

ユーザベースの Product Team には、全社のセキュリティを担うチームとは別に、プロダクトセキュリティの底上げを担うセキュリティチーム、通称 Blue Team というチームがあります。

私たちはそのチームの一員として、日頃の開発業務に加えてユーザベースのプロダクトのセキュリティを横断的に向上するための活動を行なっています。

現在、 Blue Team の取り組みのひとつとして、脆弱性のリスクや対策方法について継続的に記事にまとめ、Product Team 内の各開発チームで読み合わせを行なってもらう施策を実施しています。

このテックブログが ユーザベースに興味を持っていただくきっかけになればと思っています!

前回の記事はこちら!

tech.uzabase.com

第 3 回目である今回は、サーバーサイドリクエストフォージェリ(通称 SSRF)の脆弱性について取り上げます! 実アプリケーションでの脆弱性の例を交えながら、ハンズオン形式で脆弱性のリスクや対策方法について解説していきます。

SSRF の脆弱性とは?

SSRF(Server-Side Request Forgery)とは、サーバーサイドでリクエストを送信する際に、攻撃者が意図しないリソースにアクセスする脆弱性のことです。 具体的には、以下で説明していきます。

なぜ SSRF の脆弱性?

SSRF の脆弱性を取り上げた理由を述べていきます。 OWASP TOP10 は、Web アプリケーションに関するセキュリティリスクをランキング形式でまとめたものです。 CSRF や XSS などの有名な脆弱性などと比較すると知名度が低いですが、本脆弱性を利用した攻撃は悪用の幅が広いです。 特にクラウドネイティブなアプリケーションが一般となった昨今では、脆弱性が含まれたアプリケーションが一つでもあると、クラウド上の他のアプリケーションへの攻撃につながる可能性も大きく、対策の優先度が高い脆弱性のため今回のテーマとしました。 発生件数も非常に多く、執筆時点(2024/1/16)で、JVN に登録されている脆弱性は 2023 年だけで151 件あります。 下記に有名なサービスの直近公開された SSRF の脆弱性の例を挙げます。

ある日、実際に SSRF の脆弱性を埋め込んでしまった、開発現場の話(空想)

実際にこのような記事を読んでも、SSRF の脆弱性がどのようにアプリケーションに埋め込まれるのか、どのような影響があるのか、イメージが湧きにくいと思います。 下記の会話を通して、SSRF の脆弱性がどのように埋め込まれるかを考えてみましょう。


新入社員 A さんは、ユーザー間でポートフォリオを共有するサービスを作る会社に勤めています。

ある日、ポートフォリオに URL を入力することでプレビューできる機能追加の開発をまかされました。

新入社員 A さんは、一旦サンプルとして以下のエンドポイントを持つ PoC を作成し、上長の 先輩エンジニア B さんにレビューを依頼しました。

  • ユーザーから入力されたURLからコンテンツを取得し、ポートフォリオを表示するエンドポイント(/index)
  • ユーザーサポートのため、オペレーターが入力されたURLを変更するようの公開していないエンドポイント(/secret)
graph LR
    client(Client) -->|⭕ GET /index| server[Server]
    client -->|❎ GET /secret| server

実際のアプリ構成とエンドポイント一覧

BlueTeam/techBlog/ssrf at main · uzabase/BlueTeam · GitHub

graph LR
    client[クライアント] -->|HTTPリクエスト| nginx[Nginx]
    nginx --> app[アプリケーションサーバー]

    subgraph internet[インターネット]
    client
    end

    subgraph server[サーバーサイド]
    nginx
    app
    end

アプリケーションの実際の画面を表示しています
実際のアプリケーション画面です

該当のソースコードはこちら

先輩エンジニア B さん: なるほど、これはいいね!

先輩エンジニア B さん: ただ、このアプリケーションは外部に公開していない API が見られてしまう恐れがあるよ。

新入社員 A さん: え、どういうことですか?Nginx でルート制限をしているので、外部からのアクセスはできないと思うんですけど。

events {
    worker_connections 8;
}
http {
    server {
        listen 80;
        server_name reverse-proxy;
        location / {
            proxy_pass http://app:3000/index;
        }
    }
}

先輩エンジニア B さん: それはそうだけど、このアプリケーションは、ユーザーが入力した URL をそのままアプリケーションサーバーでリクエストした結果を表示しているよね?

新入社員 A さん: そうですね。ユーザーから入力された URL をそのままアプリケーションサーバーでリクエストして、その結果を表示しています。

import { serve } from "@hono/node-server";
import { Hono } from "hono";

const app = new Hono();

app.get("/index", async (c) => {
  const url = c.req.query("url");
  let content = "";

  if (!!url) {
    try {
      const requestUrl = new URL(url);
      content = await fetch(requestUrl.origin + requestUrl.pathname).then(
        (res) => res.text()
      );
    } catch (error) {
      console.error(error);
      content = "エラーが発生しました";
    }
  }

  return c.html(indexTemplate(url, content));
});

app.get("/secret", (c) => c.json({ message: "公開していないAPIだよ" }));

const port = 3000;
console.log(`Server is running on port ${port}`);

serve({
  fetch: app.fetch,
  port,
});

先輩エンジニア B さん: じゃあ、ユーザーが入力した URL をhttp://localhost:3000/secretとかにしたらどうなると思う?

新入社員 A さん: え?とりあえず試してみます。

アプリケーション上で脆弱性を利用され、外部へ公開していない情報が漏洩している画面
実際に脆弱性を突かれて、外部へ公開していない情報を確認できています

新入社員 A さん: なんか、アプリケーションサーバーの情報が見れてしまいました。 どう対策すればいいですか?

先輩エンジニア B さん: まず、localhost にアクセスできないようにする必要があるね。 parseURL とかで、ホスト名が localhost になっていないかチェックするといいよ。

新入社員 A さん: なるほど、やってみます。

const isLocalhost = (url: string): boolean => {
  const parsedUrl = parseUrl(url);
  return (
    parsedUrl.resource === "localhost" || parsedUrl.resource === "127.0.0.1"
  );
};

app.use("/index", async (c, next) => {
  const url = c.req.query("url");

  try {
    // ユーザーからの入力をチェックする処理を追加
    if (!!url && isLocalhost(url)) {
      console.log("localhostだ。さてはSSRFを試みているな");
      return c.html(indexTemplate(url, "このURLは表示できないよ"));
    }
  } catch (error) {
    console.error("Can't parse url", error);
    return c.html(indexTemplate(url, "エラーが発生しました"));
  }
  await next();
});

先輩エンジニア B さん: いい感じだね!ちゃんと対策できているか、試してみよう!

該当のソースコードはこちら

先輩エンジニア B さん: うん、ちゃんと対策できているね!

スペシャリスト S さん: 本当に対策できているのかな? ちょっと試してみようかな。

新入社員 A さん,先輩エンジニア B さん: え、どういうことですか?

スペシャリスト S さん: 脆弱性を突くために、http://user:pass@@localhost:3000/secretとかにしてみようかな。

パッチを当てたものの、ライブラリの脆弱性を突かれてしまった状況です。
パッチを当てたものの、ライブラリの脆弱性を突かれてしまった状況です。

新入社員 A さん,先輩エンジニア B さん: え、なんで?

スペシャリスト: package.jsonを開いてもらえる?

{
  "dependencies": {
    "parseurl": "6.1.0"
  }
}

スペシャリスト Sさん: このParseUrlのバージョンはどうしてこうしたの?

新入社員 A さん: 調べて良さそうなサイトからコピペしたんですが、なんでダメなんですか?

スペシャリスト S さん: この ParseUrl のバージョンには、脆弱性があって、@が連続しているユーザー名とパスワードを含んだ URL をパースすると、正しく URL をパースできないんだよね。だから、localhost を弾く処理が効いていないんだよ。

新入社員 A さん,先輩エンジニア B さん: そ、そんなばかな。

スペシャリスト S さん: ダウンロード数の多いライブラリだから驚くよね。でも、この ParseUrl のバージョンは、2022 年に公開された脆弱性を含んだバージョンなんだよ。 CVE-2022-2216

簡単かつ素早い対策として次の 2 つの手段があるよ。

  • ParseUrl のバージョンを上げる
  • 標準ライブラリを利用する

新入社員 A さん: なるほど、ParseUrl を使わずにやってみます。

標準ライブラリを利用し、脆弱性を回避したバージョンのソースコードです

const isLocalhost = (url: string): boolean => {
  // 標準ライブラリを使用するように変更
  const parsedUrl = new URL(url);
  return (
    parsedUrl.hostname === "localhost" || parsedUrl.hostname === "127.0.0.1"
  );
};

app.use("/index", async (c, next) => {
  const url = c.req.query("url");

  try {
    if (!!url && isLocalhost(url)) {
      console.log("localhostだ。さてはSSRFを試みているな");
      return c.html(indexTemplate(url, "このURLは表示できないよ"));
    }
  } catch (error) {
    console.error("Can't parse url", error);
    return c.html(indexTemplate(url, "エラーが発生しました"));
  }

  await next();
});

新入社員 A さん: じゃあもう一回試してみます!

アプリケーションの実際の画面を表示しています。ParseURLの脆弱性を回避することで、内部の情報が見られないようになりました。
ParseURLの脆弱性を回避することで、内部の情報が見られないようになりました。

アプリケーションサーバー側でも、SSRFが試みられていることを確認できている画像です。
アプリケーションサーバー側でも、SSRFが試みられていることを確認できています。

該当のソースコードはこちら

スペシャリスト S さん: うん、ちゃんと対策できているね! SSRF の脆弱性への対策が難しいものだから、気をつけてね

SSRF の脆弱性の対策

SSRF の脆弱性は、容易に紛れ込んでしまう可能性があるものということがわかってもらえたと思います。 ここまででてきた対策方法をまとめます。

実装ミスによる脆弱性対策

ユーザーの入力を信用しない

今回のサンプルアプリケーションではユーザーから入力された URL をそのままリクエストに使用するという実装になっていました。 このような実装は、上記の例であげたように、本来アプリケーションが届けたい機能とは異なる操作を実行されてしまう可能性があります。 ここについては過去弊社のブログでも紹介されているので、ここでは簡単に説明します。 上記のブログで述べられているように、構造化テキストを文字列結合で作ることは危険です。 なぜなら、http://example.com/input/{ここにユーザーから入力された文字列}という URL を作る場合、ユーザーから入力された文字列が../secretだった場合、解釈としてはhttp://example.com/secretという URL になってしまい、任意の URL にリクエストを送信してしまう可能性があるからです。

let url = new URL("http://exmaple.com/input/../secret");
console.log(url.href);
// 'http://exmaple.com/secret'

このような場合、js では以下のように、入力部分をエンコードすることで、正しく解釈されるようにできます。 (そもそもユーザーからの入力をパスパラメータに入れてしまうと、仮に脆弱性が含まれてしまった場合、任意のパスに変更できるということなのでおすすめはしません。)

let url = new URL("http://exmaple.com");
url.pathname = "input/" + encodeURIComponent("../secret");
url.href;
// 'http://exmaple.com/input/..%2Fsecret'

許可リスト・拒否リストの利用

SSRF の脆弱性において、ユーザーからの入力を使用したリクエストにおいて、意図しないリソースへのアクセスが行われることは大きな脅威です。 ユーザーから入力を元にサーバーなどにリクエストを送信する必要がある場合は、許可リスト・拒否リストを利用し、アクセス可能なリソースを制限することは大切です。

ライブラリの脆弱性を回避する

次は、ライブラリのバグによって脆弱性が埋め込まれるパターンの対策を考えていきます。

ライブラリの脆弱性をチェックする

ライブラリの脆弱性は、大きく 2 つのパターンで混入することがあります。

  • ライブラリのバグ
  • 新たな攻撃手法

ライブラリの更新や新たな脆弱性などによって、混入する可能性もあるため、こまめにチェックする必要があります。 それらを人力で行うのは、人為的なミスやコストも考えられるので、ツールなどで自動化することをおすすめします。

  • Dependabot
  • Yamory
  • Snyk

ライブラリを使う際には、極力標準ライブラリを使用する

ライブラリの脆弱性を回避するためには、極力標準ライブラリを使用することが大切です。 本ブログで紹介した脆弱性も、標準ライブラリ以外を使用したことによる脆弱性でした。 標準ライブラリは、その言語が提供する機能です。そのため、脆弱性が発見された場合は、エコシステムへの影響が広範囲にあるため迅速に対処される可能性が高いです。 パーサーやリクエスターなどのユーザーの入力を処理する機能は脆弱性が含まれる危険性があるので、できるだけ標準ライブラリを使用することをおすすめします。

まとめ

SSRF の脆弱性の対策が難しい点は、アプリケーションごとにアクセスを許可するリソースが異なるため、ライブラリなどで一括して対策することができない点です。 そのため、上記で述べた対策は対症療法的な対策であり、最も大切なのは、外部へ公開していないリソースにアクセスできないように設計することです。 ユーザーの入力を使用した通信を利用した実装を検討している場合は、再度本当にその機能が必要なのか設計や実装を見直してみることは大切です。 その上で、ユーザーの入力を使用する場合は、許可リスト・拒否リストの利用や標準ライブラリを優先するなどの、脆弱性を含まないための対策を行うことで、SSRF の脆弱性を埋め込んでしまう可能性を低くできます。

We are hiring!!!

ブログを最後まで読んでくださりありがとうございました。 ユーザベースでは、Product Team のメンバーを募集しています! 本ブログが、ユーザベースへ関心を持っていただくきっかけになれば幸いです!

www.uzabase.com

参考文献

Page top