ハッカー専用パズルゲームを作ったので全てネタバレする

公開:2020/09/23
更新:2020/09/24
12 min読了の目安(約7700字TECH技術記事
Likes38

💰作ったもの💰

仕様

  • Account Number(数字8桁) と Password(数字4桁) によるログイン認証
  • Account Number は全て存在する(8桁なので全口座数は1億口)
  • Password を3回間違えるとロック(再試行不可)される
  • [裏設定] 銀行口座を模している為、もちろんパスワードが流出したら他者もログイン可能

ユーザーはどんな攻撃手法を用いても良い。
最近話題の1段階認証を突破してみようというパズルゲーム。

システム構成

  • JSフレームワーク: Nuxt.js 2.14.5(Vue.js 2.x)
  • トランスパイル: TypeScript 4.0
  • CSSフレームワーク: TailwindCSS
  • ホスティング: Vercel

お馴染みの構成を選択したが、Vue.jsの使用については少し後悔している(後述)

以下ネタバレ

想定する解法

基本的には以下の2つと考えている。

  • ☠️リバースブルートフォース攻撃☠️
    件の金融事件で脚光を浴びた攻撃手法。
    暗証番号の総当たり(ブルートフォース攻撃)は一般的なシステムだと試行回数に制限を設けているが、ユーザーIDの様な公開情報については秘匿性が低いため無制限に試行できるシステムが多い。そこを突いてパスワードは固定のままIDの方を総当たりするという攻撃。
    特に当ゲームのような、認証情報に数値しか許容していないシステムだと少ない試行回数で突破できるため致命的な脆弱性となる。

  • 😈リバースエンジニアリング😈
    JavaScriptのみで構成している為、コードを解析してパスワードの取得が可能。
    当初はNode.jsサーバー等を用意して認証処理を隠蔽するつもりだったがガチ攻撃された場合、サーバーか私の財布が死ぬと思い断念。
    しかし「ハッカー専用」と銘打っている以上、コードを覗くだけで突破されては面白くないので悪足掻きとして難読化を施している。

仕組みについて

🔐パスワード生成

仕様として、口座番号とパスワードの紐付きを作らないといけない(パスワードが流出したら誰でもログインできるように)
最初は愚直に1億口座分のパスワードマッピングを定数で用意しようと考えた。
しかし上述のコードを隠蔽できない事情によりリテラル値で持つのは避けたかった為、下記のライブラリを採用した。
seedrandom.js
これを用いてシード値から再現性のある乱数を生成[1]する。

下記のように口座番号をシード値とした乱数を生成した後、整形してパスワードとしている。

const seedrandom = require('seedrandom')

function authenticated(account: string, password: string): boolean {
  const rng = seedrandom(account)
  const _pass = String(Math.round(rng() * 10000)).padStart(4, '0')
  return _pass === password
}

これで口座番号とパスワードの紐付きを再現している。

seedrandom.jsの使用感

  • 🙆‍♂️ 良かった所
    • 直感的に使える
    • 複数の乱数アルゴリズムに対応している
    • Math.random()をラップできる為、テストにも使えそう
  • 🙅‍♂️ 頑張って〜
    • 型定義がない(2020/09現在、issueにて対応中)

個人的には「再現性のある乱数を生成する処理」というのはバーコードバトラーライクのゲームを作る際に重宝するので、有難いライブラリ。

状態管理, 認証処理

本筋と外れるが、クライアントで認証処理を行う為に口座番号と暗証番号をグローバル管理する必要があった。
Vuexはオーバースペックなので状態をInjectするプラグインを自作してstoreとして使用した。

~/plugins/auth.ts
import Vue from 'vue'
import { Plugin } from '@nuxt/types'
import { Seedrandom } from '../types/seedrandom'
const seedrandom = require('seedrandom') as Seedrandom

type InjectTypeAuth = {
  /**
   * 口座番号
   */
  accountNumber: string
  /**
   * 暗証番号
   */
  password: string
  /**
   * 認証処理
   * seedから4桁の乱数(先頭0埋め)を生成し、passwordとの比較結果を返却する
   * @param {string} seed シード値となる値
   * @param {string} password パスワード
   */
  authenticated(seed: string, password: string): boolean
}

declare module '@nuxt/types' {
  interface Context {
    $auth: InjectTypeAuth
  }
  interface NuxtAppOptions {
    $auth: InjectTypeAuth
  }
}
declare module 'vue/types/vue' {
  interface Vue {
    $auth: InjectTypeAuth
  }
}

type State = {
  accountNumber: string
  password: string
}

/**********************************************
 * 認証情報プラグイン
 * @param {Context} ctx
 * @param {(key: string, value: any) => void} inject
 */
const AuthPlugin: Plugin = (_ctx, inject) => {
  /**
   * Observable properties
   */
  const state = Vue.observable({
    accountNumber: '',
    password: '',
  } as State)

  function authenticated(seed: string, password: string): boolean {
    const rng = seedrandom(seed)
    const _pass = String(Math.round(rng() * 10000)).padStart(4, '0')
    if (_pass === password) state.password = _pass
    return _pass === password
  }

  /**
   * Injection
   */
  inject('auth', {
    get accountNumber() {
      return state.accountNumber
    },
    set accountNumber(accountNumber: string) {
      state.accountNumber = accountNumber
    },
    get password() {
      return state.password
    },
    authenticated,
  })
}
export default AuthPlugin

※ 実際の乱数生成処理はもう少しノイズを入れてるので、このまま動かしても同じパスワードは生成されません

このauthenticatedをEnterボタン押下時と、直アクセスを防ぐ目的でログイン後ページ内のvalidate()hookでも呼び出して認証処理としている。

当然ですが、フロントエンドでパスワードの一致チェック等の認証処理を行ってはいけません。

Vue3のRCが外れて正式リリースとなったが、Reactivity API等を活用したVuex(ver 5)のリリースはまだ先の様なので暫く状態管理はこの手法に落ち着きそう。

攻撃方法の紹介

以上を踏まえ、解法の一つであるリバースブルートフォース攻撃の実施方法を紹介していく。
尚、当ゲームでは外部からブラウザ操作しやすいように主要な要素に対してid属性を付与している。
振っているID一覧

前提知識: JavaScript の場合

システム構成」の節でも触れたが、Vue.jsの採用を後悔したのはここ。

システムがHTML+PureJSの構成であれば、ブラウザの開発者コンソールから下記の様に実行して入力値を動的に与える事が可能。

const digit1 = document.getElementById('digit1')
digit1.value = "1"

これは一般的な方法だし、私も当初このやり方を想定していた。
しかしVue.jsの場合、inputイベントを検知してVueインスタンス内に保有するデータを更新する。
直接input要素のvalue値を書き換えてもイベントは発火せず、データは更新されない。
この挙動を想定しておらず、ユーザーに無駄なハードルを与えることとなってしまった。

ではどうするのか。
当ゲームには数値をカウントアップ/カウントダウンするボタンを実装している。
これ

金融システムって誰得UI多いよな という遊び心で実装したボタンだが、Vue内のデータを書き換えるイベントに直結している。
このボタンをプログラムからクリックすることが出来れば口座番号入力の自動化が可能となる。

ここから先の解説は、既にUNLOCK成功された@yoneappさんが記事を書かれている為、そちらに任せることとする。
リバースブルートフォース攻撃を使ってUNLOCK BANKの口座に不正ログインして優勝する(Zenn)
(改めて解説記事の執筆ありがとうございます🙇‍♂️)

前提知識: Vue.js の場合

Vue.jsには vue-devtools というデバッグ用のブラウザ拡張が存在する。
これを使えばコンポーネント構成を覗きつつ、各種データの書き換えやイベント発火が可能となるが通常はProduction環境で開けない。
しかし、開発者ツールを使いこれをこじ開ける方法がある。

本番公開されているサイトで Vue devtools を使う裏技(Qiita)

厳密にはソースの難読化を施してるのため上記記事と全く同じではないが、ソースを検索すればdevtoolというキーワードは見つかるはず。
あとはその後ろにブレークポイントを張り、_0x1bd719.devtools = true を実行すれば開発者ツールを開けることが可能。

ProductionでDevtoolsを開いた図

図の状態(Devtoolsでコンポーネントを選択した状態)だと、開発者コンソール上で$vm0というオブジェクトが使用出来る。これは選択したコンポーネントのVueインスタンスであり、コンポーネント内に存在するデータやメソッドが全て内包されている。
あとはそこからアタリを付けて、自動化プログラムを組めば良い。
実際には下記をイジれば入力操作の自動化が可能となる。

$vm0.accountNumbers // 口座番号(1桁ずつ格納した配列)
$vm0.password // パスワード
$vm0.enter() // Enterボタン押下時の処理を実行

初めてこれ(Productionでdevtoolsが使えること)を知った方はセキュリティ面に不安を覚えるかもしれませんが、それはお門違いです。
フロントエンドで保持するデータはユーザーから自由に改ざんされる前提であるべきです。

前提知識: Nuxt.js の場合

Nuxt.jsで作られたアプリケーションはwindow直下に$nuxtというオブジェクトが作られる。
そこには全ての情報が含まれており、上記のようにDevtoolsを開かずとも開発者コンソールからデータの書き換えやメソッドの実行が可能。
しかし内包する情報量が膨大な為、開発者ではない第三者が操作したい対象を探すのは一苦労かもしれない。
ちなみに当ゲームでは下記が自動化に必要な対象となる。

window.$nuxt.$children[1].$children[0].$children[0].accountNumbers
window.$nuxt.$children[1].$children[0].$children[0].password
window.$nuxt.$children[1].$children[0].$children[0].enter()

その他の手法

SeleniumPuppeteer 等を用いたブラウザ操作の自動化があげられる。
今のところ、これらを使ってUNLOCKしたという報告は観測していない。ブラウザの開発者ツールが万能すぎる。

悪意が無くともサーバーに高負荷をかける行為は法律により罰せられる可能性があります。
しかし、当ゲームで行う分には問題ありません。存分に攻撃してください。

更にネタバレ

もしproduction環境でログイン後のページを確認する必要が出た場合を考慮して、

  • Account Number: 1145-1419
  • Password: 1919

と入力したら開発コンソールに上記口座のパスワードが出力されるようになっている。
攻撃するのは面倒くさいけどログインしてみたい、という方はどうぞ。

確率について

数値4桁のパスワードというのは 0000 ~ 9999 の1万パターンしかない。
パスワードを固定にして、口座番号を総当たりした場合の確率は下記で求められる。

1(9999/10000)x1-(9999/10000)^x
x=試行回数x=試行回数

(間違ってたらご指摘ください)
確率を表にするとこうなる。

試行回数 HITする確率
3回 0.03%未満
10回 約0.1%
100回 約1%
1,000回 約10%
10,000回 約63%
100,000回 約99%

つまり、口座番号8桁(1億パターン)全て試行せずとも、10万回程度で1口座はUNLOCKできてしまう。
下5桁総当たりすればいい計算で、実際にTwitterでUNLOCKされた方の反応も「思ったより早かった」という声が多かった。
当ゲームはパスワードを乱数によって生成しているが、これが本当の銀行口座の場合、パスワードの偏りが生じるはずなので更にUNLOCKは容易となるだろう。

おわり

ネタのつもりで作ったんですが思いのほか反響があって、解説記事を書いて頂いたり、難読化をデコードしてリバースエンジニアリングまでやって下さってる方もいて、個人開発冥利に尽きるなと思いました。
他にも「こんなやり方あるよ」という方がいましたらご連絡ください。

長文コード貼る時は、折り畳み(タイトル: ファイル名)+コードスニペットが見やすくていい感じ。

脚注
  1. JavaScriptにはシード値から乱数を生成する機能が備わっていない ↩︎