スプーキーズのちょっとTech。

SPOOKIES社内のより技工的な、専門的なブログページです。

AlpacaHackのDaily CTFを解いてみた!

前置き

こんにちは! 最近、CTFに触れる機会が少なくなってしまい、ひさしぶりに触りたくなり、AlpacaHackさんのデイリーチャレンジに挑戦してみました!

挑戦した問題はこちら。

image.png (126.9 kB)

この問題では、Linuxのバイナリファイルが与えられます。 どうやら、シンプルなフラグチェッカーのようですが、少し様子がおかしいコードの予感がします。

今回のゴールは、実行時に展開される検証ロジックを取り出し、フラグ再構築まで到達することです。

babar:$ ./chal
flag: hogehoge
wrong...

実装はどうなってる?

まずは入口として、main が何をしているかを読み解きます。

mainの実装は以下のような内容でした。 デコンパイラを使うと、入口の挙動を短時間で把握しやすく、重宝しています。

__int64 __fastcall main(int a1, char **a2, char **a3)
{
  unsigned int i; // [rsp+8h] [rbp-B8h]
  char *v5; // [rsp+10h] [rbp-B0h]
  void *dest; // [rsp+20h] [rbp-A0h]
  char s[136]; // [rsp+30h] [rbp-90h] BYREF
  unsigned __int64 v8; // [rsp+B8h] [rbp-8h]

  v8 = __readfsqword(0x28u);
  printf("flag: ");
  if ( !fgets(s, 128, stdin) )
    return 1;
  v5 = strchr(s, 10);
  if ( v5 )
    *v5 = 0;
  if ( strlen(s) == 36 )
  {
    dest = mmap(0, 0x11Bu, 7, 34, -1, 0);
    if ( dest == (void *)-1LL )
    {
      return 1;
    }
    else
    {
      memcpy(dest, &unk_4020, 0x11Bu);
      for ( i = 0; i <= 0x11A; ++i )
        *((_BYTE *)dest + (int)i) *= 115;
      if ( ((unsigned int (__fastcall *)(char *))dest)(s) )
        puts("correct!");
      else
        puts("wrong...");
      return 0;
    }
  }
  else
  {
    puts("wrong...");
    return 0;
  }
}

以下のソースコードから、フラグの文字数は36文字で、フラグがあっているかどうかを (unsigned int (__fastcall *)(char *))dest)(s) でチェックしているように見えます。

解析の壁 (1): mmapって?

ここでは、最初の方に呼び出されている関数、mmap の呼び出し意味を仕様ベースで確認してみます。

https://mkguytone.github.io/allocator-navigatable/ch73.html

を参照すると、Windows APIでいうところのVirtualAllocに近い役割の関数だということがわかりました。

void *mmap(void * addr , size_t length , int prot , int flags , int fd , off_t offset );

ここで、実際の使用例を見ると

  • 長さが283バイト
  • メモリ保護属性が7
  • flagsが34

で、新しく領域を確保していることがわかります。

dest = mmap(0, 283u, 7, 34, -1, 0);

ここで注目したいのが、保護属性が7であるということでした。

https://github.com/torvalds/linux/blob/master/arch/alpha/include/uapi/asm/mman.h#L7

PROT_READ/WRITE/EXEC の定義値を参照すると、

#define PROT_READ    0x1     /* page can be read */
#define PROT_WRITE  0x2     /* page can be written */
#define PROT_EXEC   0x4     /* page can be executed */

になっており、

つまり今回の場合、7はこれらをすべて組み合わせたメモリ保護属性であることがわかります。

>>> 0x1 | 0x2 | 0x4
7

ここからわかるのは、今回のメモリ確保で、実行可能かつ読み書きできる領域を動的に確保している点です。

ここは実装上の本質でもあります。 RWX領域を確保して、その場でバイト列を書き換えたうえで関数として実行しているため、構造としてはJIT・シェルコード実行・unpackingでよく出てくる典型パターンです。

先ほど、 (unsigned int (__fastcall *)(char *))dest)(s) で、フラグのチェックを走らせていることが読み取れました。このdestに動的にコードを展開し、フラグチェックのロジックを実行していることがわかりました。

解析の壁 (2): どうやってコードを展開している?

展開ロジック

この問題を解くには実際に展開しているロジックを発見し、どのようなデータが入っているかを特定する必要がありそうです。

mmapの結果をdestに代入した後、以下のような処理を動かしています。 ただ単に、プログラムに含まれるデータをコピーするだけではなく、115を乗算する単純な変換が施されていると推測できます。

   memcpy(dest, &unk_4020, 0x11Bu);
      for ( i = 0; i <= 0x11A; ++i )
        *((_BYTE *)dest + (int)i) *= 115;

115倍の意味をもう一段掘る

この* 115は、mod 256上での1バイト変換です。 1バイト演算はオーバーフローしても下位8ビットだけが残るため、実質的には次の式になります。

decoded = (encoded * 115) mod 256

ここで重要なのは、115がmod 256で可逆かどうかです。

  • gcd(115, 256) = 1
  • したがって、115にはmod 256で逆元があります

実際、逆元は187です。

115 * 187 mod 256 = 1

つまり、変換は次のように往復できます。

  • 復号(このバイナリが実行時にやっている処理): x -> (x * 115) & 0xFF
  • 逆変換: x -> (x * 187) & 0xFF

「なぜ115なのか」に対する答えは、1バイト空間で可逆な係数を選んでいるためです。これにより、定数倍だけで軽量に難読化と復元を両立できます。

バイトコードをダンプ&復号化しよう

実際に確認したいのはこの部分なので、まずは対象バイト列をダンプする必要があります。 筆者はIDAの無料版を利用していたため、IDA Pythonによる自動抽出は今回は見送ることにしました。

代わりに、バイナリファイルからバイト列をスキャンする簡単なPythonスクリプトをAIで書き、実際のバイトコードが存在する物理アドレスを特定します。

C:\blog>python pattern_scanner.py chal "\xCB\x40\x80\x05\x7B\xF5\x27\xF5\xBB\x00"
Pattern bytes: cb 40 80 05 7b f5 27 f5 bb 00
Matches: 1
- offset(dec)=12320, offset(hex)=0x3020

先頭から0x3020バイト目がバイトコードの開始位置と判断できたため、抽出用コードを用意しました。

改めて、今までの前提を整理すると、

  • バイトコードの物理アドレス = 0x3020
  • バイトコードの長さ = 283バイト
  • 乗算する数 = 115

これで、フラグをチェックするロジックのバイトコードを抽出できます。

C:\blog>python bytecode_decryptor.py
Input: chal
Offset: 0x3020
Size: 0x11B (283 bytes)
Output: extracted.bin
Done

解析の壁 (3): どうやってフラグを再構築する?

この章では、逆アセンブル結果から文字列比較の実体を復元してみます。

抽出したバイトコードをIDAで解析すると、フラグチェックのロジックが確認できました。

image.png (77.6 kB)

全体は以下のとおりです。

seg000:0000000000000000                 xor     eax, eax
seg000:0000000000000002                 cmp     byte ptr [rdi], 41h ; 'A'
seg000:0000000000000005                 jnz     locret_11A
seg000:000000000000000B                 cmp     byte ptr [rdi+23h], 7Dh ; '}'
seg000:000000000000000F                 jnz     locret_11A
seg000:0000000000000015                 cmp     byte ptr [rdi+1], 6Ch ; 'l'
seg000:0000000000000019                 jnz     locret_11A
seg000:000000000000001F                 cmp     byte ptr [rdi+10h], 6Eh ; 'n'
seg000:0000000000000023                 jnz     locret_11A
seg000:0000000000000029                 cmp     byte ptr [rdi+4], 63h ; 'c'
seg000:000000000000002D                 jnz     locret_11A
seg000:0000000000000033                 cmp     byte ptr [rdi+14h], 39h ; '9'
seg000:0000000000000037                 jnz     locret_11A
seg000:000000000000003D                 cmp     byte ptr [rdi+5], 61h ; 'a'
seg000:0000000000000041                 jnz     locret_11A
seg000:0000000000000047                 cmp     byte ptr [rdi+8], 61h ; 'a'
seg000:000000000000004B                 jnz     locret_11A
seg000:0000000000000051                 cmp     byte ptr [rdi+17h], 61h ; 'a'
seg000:0000000000000055                 jnz     locret_11A
seg000:000000000000005B                 cmp     byte ptr [rdi+12h], 34h ; '4'
seg000:000000000000005F                 jnz     locret_11A
seg000:0000000000000065                 cmp     byte ptr [rdi+1Ah], 6Eh ; 'n'
seg000:0000000000000069                 jnz     locret_11A
seg000:000000000000006F                 cmp     byte ptr [rdi+9], 77h ; 'w'
seg000:0000000000000073                 jnz     locret_11A
seg000:0000000000000079                 cmp     byte ptr [rdi+0Ch], 69h ; 'i'
seg000:000000000000007D                 jnz     locret_11A
seg000:0000000000000083                 cmp     byte ptr [rdi+0Ah], 34h ; '4'
seg000:0000000000000087                 jnz     locret_11A
seg000:000000000000008D                 cmp     byte ptr [rdi+0Dh], 5Fh ; '_'
seg000:0000000000000091                 jnz     locret_11A
seg000:0000000000000097                 cmp     byte ptr [rdi+0Fh], 69h ; 'i'
seg000:000000000000009B                 jnz     short locret_11A
seg000:000000000000009D                 cmp     byte ptr [rdi+1Eh], 70h ; 'p'
seg000:00000000000000A1                 jnz     short locret_11A
seg000:00000000000000A3                 cmp     byte ptr [rdi+0Eh], 6Dh ; 'm'
seg000:00000000000000A7                 jnz     short locret_11A
seg000:00000000000000A9                 cmp     byte ptr [rdi+20h], 63h ; 'c'
seg000:00000000000000AD                 jnz     short locret_11A
seg000:00000000000000AF                 cmp     byte ptr [rdi+21h], 61h ; 'a'
seg000:00000000000000B3                 jnz     short locret_11A
seg000:00000000000000B5                 cmp     byte ptr [rdi+6], 7Bh ; '{'
seg000:00000000000000B9                 jnz     short locret_11A
seg000:00000000000000BB                 cmp     byte ptr [rdi+15h], 61h ; 'a'
seg000:00000000000000BF                 jnz     short locret_11A
seg000:00000000000000C1                 cmp     byte ptr [rdi+2], 70h ; 'p'
seg000:00000000000000C5                 jnz     short locret_11A
seg000:00000000000000C7                 cmp     byte ptr [rdi+22h], 21h ; '!'
seg000:00000000000000CB                 jnz     short locret_11A
seg000:00000000000000CD                 cmp     byte ptr [rdi+13h], 31h ; '1'
seg000:00000000000000D1                 jnz     short locret_11A
seg000:00000000000000D3                 cmp     byte ptr [rdi+1Dh], 6Ch ; 'l'
seg000:00000000000000D7                 jnz     short locret_11A
seg000:00000000000000D9                 cmp     byte ptr [rdi+1Ch], 34h ; '4'
seg000:00000000000000DD                 jnz     short locret_11A
seg000:00000000000000DF                 cmp     byte ptr [rdi+18h], 5Fh ; '_'
seg000:00000000000000E3                 jnz     short locret_11A
seg000:00000000000000E5                 cmp     byte ptr [rdi+11h], 31h ; '1'
seg000:00000000000000E9                 jnz     short locret_11A
seg000:00000000000000EB                 cmp     byte ptr [rdi+19h], 31h ; '1'
seg000:00000000000000EF                 jnz     short locret_11A
seg000:00000000000000F1                 cmp     byte ptr [rdi+1Fh], 34h ; '4'
seg000:00000000000000F5                 jnz     short locret_11A
seg000:00000000000000F7                 cmp     byte ptr [rdi+0Bh], 69h ; 'i'
seg000:00000000000000FB                 jnz     short locret_11A
seg000:00000000000000FD                 cmp     byte ptr [rdi+3], 61h ; 'a'
seg000:0000000000000101                 jnz     short locret_11A
seg000:0000000000000103                 cmp     byte ptr [rdi+1Bh], 5Fh ; '_'
seg000:0000000000000107                 jnz     short locret_11A
seg000:0000000000000109                 cmp     byte ptr [rdi+7], 6Bh ; 'k'
seg000:000000000000010D                 jnz     short locret_11A
seg000:000000000000010F                 cmp     byte ptr [rdi+16h], 63h ; 'c'
seg000:0000000000000113                 jnz     short locret_11A
seg000:0000000000000115                 mov     eax, 1

https://th0x4c.github.io/blog/2013/04/10/gdb-calling-convention/

から、Linux x64の関数呼び出し規則を見てみると、

  • 第一引数はrdi
  • 戻り値はrax

であることがわかります。

つまりこのコードは、第1引数に入った文字列を1文字ずつcmpで比較し、完全一致を判定する単純なロジックのようです。

ただし、比較順と文字位置は一致していないため、上から読むだけでは復元できません。そこでPythonで機械的に復元してみます。

ここは難読化として地味に効いているポイントです。 通常のstrncmp系の比較であれば文字位置と比較順が揃いやすいですが、今回は[rdi+offset]のoffsetがランダム順で登場します。 そのため、人間が逆アセンブリを目で追うと、断片的な比較が並んでいる状態になり、正しい並びを頭の中で再構成しづらくなります。

また、cmpの直後にjnzが連続するため、制御フローとしては単純でも見た目は散らされます。 今回の復元はこの散らし方に対して、命令列を「文字列比較データ」として抽出し、最後にインデックスでソートし直す方針です。

具体的には、復号後バイト列を1バイトずつ走査し、cmp byte ptr [rdi+offset], imm8 の命令パターンを拾っていきます。

x86-64で今回対象とした命令形式は主に2つあります。

  • 80 7F xx yy: cmp byte ptr [rdi+xx], yy
  • 80 3F yy: cmp byte ptr [rdi], yy(offsetが0の特殊形)

前者は3バイト目がインデックス、4バイト目が比較文字なので、そのまま offset -> 文字 の対応表に格納します。 この対応表を最終的にインデックス順へ並べることで、比較順がバラバラでも元の文字列を復元できます。

from pathlib import Path

FLAG_LENGTH = 36
CMP_LENGTH = 4

INPUT_FILE = Path("./extracted.bin")
raw = INPUT_FILE.read_bytes()

collected = "????????????????????????????????????"
collected = list(collected)

current_pos = 0
while current_pos + CMP_LENGTH < len(raw):
    if raw[current_pos:current_pos + 2] == b'\x80\x7F':
        flag_pos = raw[current_pos + 2]
        flag_chr = chr(raw[current_pos + 3])
        collected[flag_pos] = flag_chr
    current_pos += 1
    
print("Collected flag:", "".join(collected))

この方法で、ほぼ完成した状態のフラグを抽出できます。

PS C:\blog> python .\flag_reconstructor.py
Collected flag: ?lpaca{kaw4ii_min1419aca_1n_4lp4ca!}

先頭1文字は、命令のバイトコード形式が異なるため当初の抽出条件に含まれていませんでした。 該当命令を確認するとASCIIでAを示しているため、これを補完します。

80 3F 41                                                        cmp     byte ptr [rdi], 41h ; 'A'

つまり今回のflagは、Alpaca{kaw4ii_min1419aca_1n_4lp4ca!} です!

以上で解答に到達できました。

別解の視点

今回は静的解析で完走しましたが、同じ問題は別ルートでも到達できます。 AIに聞くだけでも、

  • gdbmmap後の領域をメモリダンプし、展開後コードを直接逆アセンブルする
  • エミュレータ(例: Unicorn)で展開コードだけを実行し、比較先データを観測する
  • LD_PRELOADmmap/memcpy/間接call周辺をフックし、実行時のバイト列を取得する

のような解法があるようです。

振り返り

今回の問題は、一見すると動的に展開される複雑なロジックに見えましたが、実際には単純な文字列比較を難読化したものでした。

特に印象的だったのは、mmapを用いた実行時コード展開です。 これにより、静的解析ではコードの本体が見えない状態になっており、「まず中身を取り出す」というステップが必要になり、単純な解析では進めないようになっていました。

これからも定期的にAlpacaHackに取り組み、他のCTFでも活躍できるように頑張ります!!

レイマーチングをやってみよう

みなさん、こんにちは!多分ctfの時のブログ振り、こまさんです!

今日はなんか面白い事したいな~と思って、多少得意分野の3D系からちょっとお話してみようかな、と思います!

実際にWebGL2でぐりぐりうごかせるデモもあるので、ぜひ触ってみて下さいね!

そもそもレイマーチングとは何ぞや

一言でいうと、3Dのレンダリング(シーンを描画すること)の方法の一つです。

とまぁこれだけじゃ「はぁ。そうですか。。」としかならないと思うので、ちょっとしっかり説明していきますね!「そんなの知っとるわ!」という方はガンガン飛ばしてもらって大丈夫です!

まぁそんなわけで、「レイマーチング」について語る前に、ここ5年強ぐらいで(特にゲームを触る方なら)聞くことが多くなったであろう「レイトレーシング」というものから話そうかな、と思います。

こちらは、「我々が見ている視野は、目に入ってきた光の色や強さで決まるよね。じゃあ逆に目の方から光線を追跡してその線が光源の方に近づけば明るい。みたいな感じで計算できるんじゃね?」といった感じの方法です。

読んで字の如く、レイ(=Ray、光線)をトレース(=Trace、追跡)するんですね。

ただこれってかなり処理の量が多いんです。というのも、光を追跡する、と言っても反射の計算が必要になります。壁に当たったら反射させて、また当たったら反射させて、、とやって光源にある程度まで近づけば光に衝突した、という感じで、色を計算したりするのですが、、、

もちろん計算する反射の回数を制限すればいいよね、となるのはその通りなのですが、あまり少なくしすぎると今度画質が苦しいものになってしまいます。。

でも昨今のGPUの進化によってある程度リアルタイムでもできるようになってきていて、実際ゲームでもレイトレーシングを使って画質の向上をできるものが結構あります。

まぁちょっと脇道に逸れてしまいましたが、今回話そうと思っていた「レイマーチング」はこのレイトレーシングの一種です。

ですが、大きな違いがあります。まず2つ、

  • 基本的に反射は計算しません!(これは計算することもできます)
  • そして次に、光線を追跡する際にレイトレーシングは、オブジェクトに当たるところまでを1ステップで進行させます。それに対して、レイマーチングは少しずつ進めていく(レイ(=Ray、光線)がマーチ(=March、行進)するということですね!)という感じ。そして一度衝突したらそこで終わりです。

ここから分かるように、よりきれいでリアルに近いような描写をするときにはレイトレーシングの方が向いています。

というか基本的にはレイトレーシングの方が優秀です。

ですが、今回レイマーチングに的を絞った理由は、3つ目の違いがとても重要で、限られた場面ではレイマーチングがとても優秀なんです。 その違いは、

  • オブジェクトの形状の定義が「関数」によって行われる

というところです。

正確に言うと、

  • 「ある点が与えられたときにその点から描画したいオブジェクトの表面までの最短距離」を求める関数

を定めると、そのオブジェクトが描ける、という感じです。

正直多分ほとんどの人は「なんでそれで描けるんだ???」と思ったと思いますが、今回は細かい事は省略して、実際にどんな感じのものが描けるか、というデモを通しながらレイマーチングの魅力を見せていきたいかな、と思います。

詳しいことが知りたい人は色々調べてみてくださいね!とっても奥が深いです


まずは手始めに、立方体!

ここから先のデモはWebGL2を使用して描画しています!比較的古い端末だと、重かったりそもそも見れなかったりするので、そこはご留意ください!

まずはとてもシンプルに立方体を1つです。

回転やズームもできます! (PCならドラッグで回転、ホイールでズームイン/アウト。モバイルならタッチで回転、ピンチ操作でズームイン/アウトできます)

フルスクリーン表示に対応している環境では、キャンバスの右上にボタンがあると思うので、スペックに自信のある方はぜひ大画面で楽しんでください。(描画するピクセル数が上がるので結構重いです!ご注意を!)

ブーリアン演算

次にとても強力なレイマーチングのメリットを一つ紹介してみます。

見出しで「ブーリアン演算」といったのでCADやモデリングなどを触ったことのある人はピンときたかもしれませんが、要は2つのオブジェクトの「和・差・積」です。

  • 和(Union): 二つのオブジェクトの合体ですね。
  • 差(Subtract): 片方のオブジェクトからもう一方のオブジェクトの重なっている部分をくり抜く感じです。
  • 積(Intersect): 二つのオブジェクトの重なっている部分だけを残す感じです。

これを形状に対して行うのって難しそう、と思ったかもしれませんが、レイマーチングでシーンを描画する場合驚くほど簡単になります。

ここで、先程言った「オブジェクトの形状を決める関数」を距離関数(以下ではSDFと呼称)と呼びます。

ここで立方体と球があるとして、さっきの3つの操作をしてみます。

立方体のSDF(最初のデモで使った「立方体を表す関数」)をf、球のSDF(同じように「球を表す関数」)をgとします。 そして先程の3操作を行った形状もまたSDFで表現されます。

  • 和(Union): U(x) = min(f(x), g(x))
  • 差(Subtract): S(x) = max(f(x), -g(x))
  • 積(Intersect): I(x) = max(f(x), g(x))

これだけです。二つのオブジェクトを表す関数で距離を計算して、それの小さいほうや大きい方をとる(差は符号を反転させる必要がありますが)。 これだけで「それぞれ3つの操作を行った形状を表す関数」になっちゃうんです。

そしたらその関数を使って描画すると、合体してたり、くり抜かれたり、重なってる部分だけになったり、と面白いです。

そんなわけで次のデモでは、立方体と球体を同じ配置でそれぞれ3つの演算をしてみています。

左から順に「和」「差」「積」です。

フラクタル図形を描画できる。

これがもう一つの強力なレイマーチングのメリットというか強みです。

フラクタル図形、というのは「自己相似図形」、つまり図形の一部分が全体と同じような構造になっている。というようなものです。

今回はその中から「メンガーのスポンジ」と「マンデルバルブ」というものを描画してみました。

もしかしたら集合体恐怖症の方は苦手かもしれないので注意してくださいね。

メンガーのスポンジはメッシュを細かくしていったら描画できそうだと思うかもしれません。まぁもちろんできなくはないのですが、再帰的に穴をあけていく関係上、指数的にメッシュの数が増加してしまうのですぐに限界が来てしまいます。

レイマーチングはこういうものを効率的に描画することができます。

(今回のデモでは8階層まで穴をあけています。メッシュデータに起こそうとしたらとんでもない頂点数になるはずです)


終わりに

いかがでしたか? 多分初めて知った人なら、なかなか新しい感覚を得られたんじゃないかな、と思います!

使う場面が来るかは分かりませんが、もしこれを見た人の創作などに新しいインスピレーションを提供できればうれしく思います。

あと最後に、ソースコードはscriptタグで埋め込んであるので、興味のある方はDevtoolsとかで取り出してもらって自由に研究したりこねこねしたりしてみてくださいね~(Gistとかに上げてる感じじゃなくてすみません😅)

それでは!

仮想マシン上で動作するJavaScriptコードに出会った話

キッカケ

日頃から、Webサイトを閲覧する際に「このサイトはどのようなコードで動作しているのか」という観点で、DevToolsを開いて実装を確認することが多い。

ある日、そうした調査の中で、通常のJavaScriptとは明らかに異なる構造を持つコードに遭遇した。確認してみると、それは単なる難読化ではなく、仮想マシンそのものをJavaScript上に実装し、その上で独自の命令列を実行するという手法が用いられていた。

Javaのように、元のコードを一度独自バイトコードへコンパイルし、それを仮想マシン上で解釈実行する構成になっており、ブラウザ上で動作するJavaScriptとしては非常に異質なものである。

構造を理解しようとコードを追い始めたものの、静的に読むだけでは処理内容を把握するのは困難だった。しかし同時に、「これは解析対象として非常に興味深い」と感じ、本格的に挙動を調査してみることにした。これが、今回の解析を始めたきっかけである。

仮想マシンベースの難読化とは

通常の難読化といえば、変数名を意味不明な文字列に置き換えたり、文字列リテラルを暗号化したりする手法が一般的です。しかし、仮想マシンベースの難読化は全く異なるアプローチを取ります。

  1. 元のJavaScriptコードを独自の命令セットにコンパイル
  2. その命令を実行する仮想マシンをJavaScriptで実装
  3. 実行時に仮想マシンが命令を解釈・実行する

実際のコード例

実際に見つけたコードは、概ね次のような構造をしていました

// デコード処理
var B = S("ながーいバイトコード", "C8p6e3HND=F...");

...

// 命令関数一覧
var P = new Proxy("ながーいバイトコード".split("|"), {
    get: function(T, n) {
        return new Function(u(T[r[Number(n)]], "7pjXi...").map(...).join(""))();
    }
});

...

var d = {
    d: [0],
    v: {},
};

function w(e, r) {
    e.d[g(e)] = r;
}

function A(v, u, f, a) {
    var r = v[u[0]++];
    if (r !== f[0]) {
        if (r === f[5]) return !1;
        if (r === f[1]) return !0;
        if (r === f[4]) return null;
        ...
        return u[r >> 5];
    }
};

function main(e) {
    while (true) {
        var _ = P[B[e.d[0]++]];
        var o = _(e, ...);
        if (o === null) {
            break;
        }
    }
}

ざっくり言うと、Eが命令列、Nが命令ハンドラー、dが仮想マシンの状態、lがメインの実行ループになっています。通常のJavaScriptとは全く異なる構造で、一見しただけでは何をしているのか全く分かりません。

解析・デコードをしていく

さて、このコードを解析していくわけですが、今回の目標はシンプルに設定しました。

  • 命令処理の一覧を出力する
  • 制御フローを可視化する

「よし、デコーダーを1から書くぞ!」と意気込んでみたものの、すぐに現実を思い知らされます。仮想マシンの命令セットを完全に解析して、デコーダーを実装するのは途方もない作業です。 そこで方針転換。デコード処理を1から書くのではなく、元のJavaScriptコードに解析用のコードを挿入して、動的に解析することにしました。つまり、仮想マシンを実際に動かしながら、その挙動をトレースしていくわけです。

  1. メインループに命令の実行ログを挿入
var _ = P[B[e.d[0]++]];
var evaluatePosition = e.d[0] - 1;
var o = _(e, ...);
console.debug(JSON.stringify({
    "evaluatePosition": evaluatePosition,
    "instructionText": _.toString()
}));
  1. オペランド読み取り処理にログを挿入
function b(e) {
    var witnessedValue = zx(E, e.d, I);
    console.debug(JSON.stringify({
        "evaluatePosition": e.d[0],
        "accessDirection": "read",
        "witnessedValue": witnessedValue
    }));
    return witnessedValue;
}
  1. 値書き込み処理にログを挿入
function w(e, r) {
    var evaluatePosition = g(e);
    console.debug(JSON.stringify({
        "evaluatePosition": evaluatePosition,
        "accessDirection": "write",
        "witnessedValue": r
    }));
    e.d[evaluatePosition] = r;
}

この状態でJSDOMを用いて実行すると以下のようなログが得られます。

...
{"evaluatePosition":147208,"accessDirection":"read","witnessedValue":"t"}
{"evaluatePosition":147211,"accessDirection":"read","witnessedValue":"t"}
{"evaluatePosition":4,"accessDirection":"write","witnessedValue":true}
{"evaluatePosition":147204,"instructionText":"function(n,e,a){a(n,e(n)===e(n))}"}
...
{"evaluatePosition":375,"accessDirection":"read","witnessedValue":73}
{"evaluatePosition":376,"accessDirection":"read","witnessedValue":165}
{"evaluatePosition":7,"accessDirection":"write","witnessedValue":73}
{"evaluatePosition":374,"instructionText":"function(n,e,a,v,i,r){var o=r[5];a(n,o(n)%o(n))}"}
...
{"evaluatePosition":115656,"accessDirection":"read","witnessedValue":"Object"}
{"evaluatePosition":115659,"accessDirection":"read","witnessedValue":"stringify"}
{"evaluatePosition":115660,"accessDirection":"read","witnessedValue":"function(){var s=t();s.d[3]=arguments;for(var f=0;f<arguments.length;f++)s.d[f+4]=arguments[f];return s.d[1]={Z:this,d:[0],v:[],m:u,o:d,n:u==null?void 0:u.n},s.d[0]=l,b(s),s.d[2]}"}
{"evaluatePosition":115659,"instructionText":"function(n,e){e(n)[e(n)]=e(n)}"}

ログから見えてきたこと

出力されたログを見れば、仮想マシンの動きがある程度わかる。

例えば115659の命令は

  • 115656で"Object"を読み取り
  • 115659で"stringify"を読み取り
  • 115660で関数を読み取り

つまりこれは、Object["stringify"] = function() {...} のようなプロパティの設定処理を行っていることがわかります。 命令自体は抽象的ですが、実際に読み取られている値と組み合わせることで、具体的な動作が見えてくるわけです。

難読化の本質

VMを用いた難読化は強力です。コードを見ただけでは、何をしているのか全く理解できません。 しかし、今回の解析を通して得られた知見は、「完全に理解しなくても、動かして観察すれば大体わかる」 ということでした。 静的解析は困難でも、動的解析なら十分に追跡可能です。ログを仕込んで実行すれば、仮想マシンが「実際に何をやっているか」は見えてきます。 難読化されたコードと向き合うときは、「完璧に理解しよう」とするよりも、「必要な情報だけ抽出しよう」という姿勢の方が、結果的に近道になることが多いのかもしれません。

社内ツールは「ちゃんと雑」でいい

背景

社内でツールを運用していると、次のようなやり取りが定期的に発生します。

  • 「そのリンク、どこから取ってきました?」

  • 「esa に貼ってあった URL、遷移できなくないですか?」

いずれも致命的な問題ではなく、業務が止まるわけでもありません。
そのため長い間、「まあ困るけど仕方ない」という扱いのまま放置されてきた類の問題です。

ツール自体は正しく動作している一方で、
URL を共有することが不安定、という状態でした。

画面や機能が増えるにつれて、

  • 条件付きパラメータを含む URL が増える

といったことが日常的に起きていました。

大きな障害ではありませんが、
違和感だけが残り続ける状態です。


「ちゃんと作るほどでもない」問題への向き合い方

この手の問題を正面から解決しようとすると、だいたい次の案に行き着きます。

  • 専用のリダイレクト API

  • 管理画面

  • ルール化された運用

実際に要件を書き出し、構成図を描き、検討もしました。

しかし整理すればするほど、コストと問題の重さが釣り合わないことが明確になります。

  • URL のパターンは多くても十数種類

  • 利用者は社内メンバーのみ

  • 権限や認可は遷移先ですでに担保されている

  • 重要なのは柔軟性より「迷わず使えること」

つまり、
解決策は軽くあるべきという性質でした。

ここで方針を切り替えました。

  • 増えたら足せる

  • 壊れにくい

  • 説明しなくていい

「きれいに作る」のではなく、
雑でいられる構成を目指すことにしました。


URL を「人が読める形」に戻す

やりたかったこと自体は非常に単純です。

  • esa や Slack に貼ったときに

  • 見ただけで用途が分かり

  • 間違えにくいこと

そのために用意したのは、次のような形式の URL です。

/共通パス/[用途]?意味のあるパラメータ

設計方針は以下の通りです。

  • [用途] でリンクの種類を明示する

  • パラメータ名は省略せず、人が読める名前にする

  • 内部で既存の複雑な URL に変換する

プロジェクト指定、メンバー指定、複数条件の集約なども、
そのまま貼っても安心できる URLとして扱えるようになりました。


AI の使い方

この仕組みを作る過程で AI も利用しましたが、
コード生成と設計の壁打ち役として使っています。

具体的には次のような点を確認しました。

  • サーバーは本当に必要か

  • 静的な構成で完結しないか

  • 設定ファイルに逃がせる部分はどこか

  • 同じ歪みを将来また生まないか

長く放置された問題は、
「前提」だけが固定されたままになっていることが多いです。

それを一つずつ疑う相手として AI を使いました。

結果として、

  • 実装は最小限

  • 設定追加で拡張可能

  • 既存運用に自然に混ざる

という形に落ち着きました。


「また増やして」と言われても破綻しないために

こうした改善で避けたいのは、
一度整えた結果、触りづらくなることです。

そのため、次の点だけは意識しました。

  • 触る場所は 1 箇所に集約する

  • 書き方に迷わない構造にする

  • 実装者以外でも触れる

完成度よりも、
雑に増やせる余白を優先しています。

社内ツールは、その方が長く生きます。


まとめ

今回の仕組み自体は目立つものではありません。

ただ、

  • esa に貼るときに迷わなくなった

  • Slack で説明文を書く頻度が減った

それだけで十分な効果がありました。

長く存在してきた不便さは、
派手に改善するよりも
考えなくていい状態に戻すことで解消されることがあります。

社内改善とは、新しいものを足すことではなく、
見て見ぬふりをしてきた違和感を片づけることなのかもしれません。

速すぎる技術の進化に疲れた私が、一周回って「基礎」に救われた話

はじめに

こんにちは、エンジニアのhiraokaです。

気がつけばもう12月。街はクリスマス一色ですが、我々エンジニア界隈はアドベントカレンダー一色ですね。

さて、いきなりですが懺悔させてください。 今年の技術トレンド、正直ぜんぜん追いきれませんでした。

毎朝起きるたびに新しいAIモデルが発表され、フロントエンド界隈では「これからは〇〇だ!いや××だ!」という議論が繰り返され…。ブラウザのブックマークには「あとで読む」つもりの技術記事が山のように積まれていますが、これらを「今年中に読む」可能性は、限りなくゼロに近いでしょう。

「置いていかれる…」という焦燥感で胃がキリキリしていた時期もありました。 でも、年の瀬に改めて振り返ってみると、ある一つの考えに達しました。

「技術が進化すればするほど、結局最後に頼れるのは『基礎』だ」 という考えです。

AIにコードを書かせて気づいた「違和感」

きっかけは、業務で生成AIを活用し始めたことでした。

確かにAIは凄いです。やりたい処理を投げれば、70、80点くらいのコードは一瞬で返してくれます。ボイラープレートを書く時間は劇的に減りました。 でも、そのコードをプロダクトに組み込んだ瞬間、予期せぬエラーが出たとします。

その時、AIは急に沈黙します。(あるいは、適当な嘘をついてループし始めます)

結局、デバッガを起動し、ログを追いかけ、原因を突き止めるのは自分自身です。そして、その時に役立った知識を思い出してみると、決して「最新フレームワークの特別な作法」ではありませんでした。

  • 「あ、これ非同期処理の順序がおかしいな」(言語仕様の基礎)

  • 「HTTPヘッダに認証トークンが乗ってないじゃん」(HTTPの基礎)

  • 「このSQL、インデックス効いてないから遅いんだ」(DBの基礎)

  • 「そもそも、問題解決のアプローチとして、このやり方は正しいのか?」(問題解決の基礎)

そう、トラブルの現場で私を救ってくれたのは、結局「何十年も変わっていない枯れた技術」やエンジニア以前の「社会人としての基礎力」だったんです。

流行り廃りの激流で「変わらないもの」を武器にする

最新のフレームワークも、便利なSaaSも、皮を剥いでみれば結局は「HTTPプロトコル」や「アルゴリズム」といった基礎の上に成り立っています。

表面のツール(How)は凄まじいスピードで変化し、陳腐化していきます。今日覚えたツールの使い方は、3年後には役に立たないかもしれません。 でも、その下にある原理原則(Why)は、10年後も、おそらく20年後も変わりません。

AI時代になって、「コードを書く」ハードルは下がりました。 その代わり、「コードの良し悪しを判断する」ハードルは上がっています。

AIが出してきたコードに対して、「動くからヨシ!」ではなく、「なぜ動くのか?」「セキュリティリスクはないか?」「パフォーマンスは最適か?」を見極める力。 その力の源泉こそが、地味で退屈に見える「基礎力」なのだと再認識しました。

来年の抱負:焦らず「深く」潜る

というわけで、来年の私のテーマは「温故知新」です。

新しい技術に飛びつくのをやめるわけではありません。でも、情報の波に溺れそうになったら、一度「地面」に足をつけようと思います。

流行りのライブラリの使い方を覚える前に、公式ドキュメントを読んでみる。

エラーが出たら、Stack Overflowの答えをコピペする前に、エラーログの英語をちゃんと読んでみる。

そうやって「基礎」という根っこを太くしていくことが、結果として、どんな激しい変化の風が吹いても倒れないエンジニアへの近道なのかもしれません。

今年も一年、お疲れ様でした。 年末年始は、難しい技術書はいったん閉じて、ゆっくり頭を休めましょう。 それでは、よいお年を!