seccamp 2022にチューターとして参加したお話

はじめに

タイトルの通りですが、セキュリティキャンプ2022に脅威解析クラス(Cトラック)のチューターとして参加させていただきましたので、その記録を残しておこうと思います。所々、去年2021の講義についても触れますが、自分は去年の受講生としての参加記を書いていないので、以下のリンクから去年と今年のCトラックのプログラムを確認してもらえるとわかりやすいかと思います。

セキュリティ・キャンプ全国大会2021オンライン プログラム:IPA 独立行政法人 情報処理推進機構

セキュリティ・キャンプ全国大会2022オンライン プログラム:IPA 独立行政法人 情報処理推進機構

Cトラックについて

今年のCトラックはC1のForensics、C2C3のKernel Exploit、C4C5のGoのソースコードの静的解析、C6C7のMalware Analysisの計4つの講義を聴く機会がありました。各講義の詳細は後に触れますが、どの講義も高難易度かつボリューミーで、濃密な5日間でした。全ての講義にハンズオンがあり、場合によっては受講者同士で相談し合って問題に取り組むなどしていて、知識を得て構造を理解するだけでなく、体験的にも理解するという素晴らしいものだったと思います。TSG内での自分の分科会の講義も見直して、よりハンズオン重視の内容へと改善していきたいですね。

また、Cトラックでは毎日講義終了後一時間程の、プロデューサーと講師を含んだCトラック関係者が集まって雑談する任意参加の会があって、ここでいろいろな話を聞くのも楽しかったです。もっともチューターは夕礼の時間と被ってしまって雑談会には途中参加という形にならざるを得ませんでしたが、これは如何ともし難いことです。

現地参加について

今年は去年とは違い、チューター(一部のトラックのチューターの人々は来ることができなかった)と講師は府中に集まったわけですが、やはり現地参加は良かったです。Twitterで拝見していた方々と顔を合わせることができましたから。休憩時間に講師方やプロデューサー方にちょっとした質問や相談を気軽にすることができたというのも助かりました。Cのチューターは二人いて、もう一人はmc4nfさんですが、彼の気さくな人柄もあってすぐに打ち解けることができて、5日間楽しかったですね。また、TSGerのつばめ先輩とhsjoihs先生と顔を合わせることができたのも嬉しかったです。

各講義について

ここからは各講義について、事前準備と講義の内容に触れて、事後の感想を記すということをやっていこうと思います。講義の内容といっても当日の内容のみを書いたわけではなく、C1とC4C5は自分の復習したことも盛り込まれていますが、復習までが講義のうちということでお願いします。C2C3とC6C7は自分の復習がまだ終わっておらず、講義内容のさわりを書いただけになっています。流石に3ヶ月たったのに何も出さないのはやばいということで中途半端な放出になっていますが、後で必ず、C2C3とC6C7の復習やWriteupを書いて出そうと思っています。 特定の講義についてまず先に確認したいかたは以下のリンクからそれぞれに飛んでください。

C1

C2C3

C4C5

C6C7

[C1] Forensics

(痕跡から手がかりを集める - アーティファクトの分析)

  • 概要

WindowsアーティファクトであるNTFSの$MFTのタイムスタンプ、永続化キー、SRUDB.dat、prefetchファイル等に対する解析を、Autopsyやその他ツール群を使ったCTF形式の演習で学ぶ。

  • 事前準備

自分は去年のseccampのC2(痕跡から手がかりを集める - アーティファクトの発見/分析技術)を受講し、それに感化されてからかなりForensicsについての勉強をしており、「インシデントレスポンス第3版」を完読したり、Eric Zimmerman's Toolsを一通り使ったり、$MFTのrawデータのフォーマットを確認したり、RedlineやFTK imagerでライブレスポンスの練習をしてみたり等々のことをやっていたので、この講義をサポートできる自信がありました。

講義を受けるための事前準備として、「Win10の仮想マシンを用意し、AutopsyをインストールしてE01ファイルを食わせてingest modulesをすること」が必要でしたので、キャンプ開始の前日(8/7)にDiscordで受講生と通話しながら環境構築をしました。比較的小さなE01ファイルでしたがingestには3時間かかったので、これは当日の朝に慌ててやってもどうにもならないやつだと気づいて念入りにメンションしたのが良かったのか、当日は受講生の皆さん全員が環境を用意して講義に臨むことができたようです。今年の受講生は事後アンケートを期限内に全員提出するという偉業を達成していましたし、優秀ですね。余談ですが、去年は僕が締め切りに遅れて足を引っ張っていました。

  • 講義内容

説明のために、去年のC2の講義との差分を共有しておくと、去年は$UsnJrnl:$JとSRUDB.datとRDP bitmap cacheの解析を行いました。今年はSRUDB.datは共通で、MFTのタイムスタンプ、永続化キー、prefetchファイル、そしてAutopsyを用いたキーワード検索が自分にとって新しく聴くことができた部分になります。

CTFの問題設定としては、マイニングぽいことをしている疑わしいファイルが見つかったWindows機において何が行われていたか、Autopsyを用いたE01ファイルの解析で読み解いていくというものでした。自分はTSG内でForensicsの分科会を開催したいという意思があり、しかしながら問題設定等に難儀していて未だ実行できていないわけですが、こうリアリティがあってかつ各アーティファクトの情報を使いながら全貌を把握する道筋が示された一連の問題を作ることができるというのは、やはり業務で実際のインシデントの解決を行なってきたプロの方にしかできないものであるなと感じました。

Windowsアーティファクトに関する分析調査の意義等の導入を終えた後、まずはファイルのhashに関して、疑わしい一連のexeファイルのhashをVirustotalで検索するなどして手がかりを掴むということを行いました。これは去年のC1(脅威調査と相関分析を用いて高度な攻撃を読み解こう)で扱っていた内容がこっちに入ってきたという感じです。次に、それらのexeファイルがASEP(Auto-Start Extensibility Point)によって永続化されているので、そのキーを特定しようという形で永続化キーを取り扱いました。Autorunsは動かして確かめたことはありましたが、AutopsyにAutoruns Pluginがあるとは初めて知りました。タスクスケジュール、Runキー、Windowsサービス等、複数の永続化キーをどのユーザーが作成したか等を含めて確認したのはいい演習になりました。

$MFTのタイムスタンプに関しては$SI属性だけでなく、$FN属性のタイムスタンプも応用で扱いました。$SIの改竄は容易なので$FNとの比較が効果的だったりするという話でした。$FNの改竄も確かインシデントレスポンス第3版で扱っていて、setmaceとかファイルをフォルダ間で移動させることで$FNを改竄する方法が載っているので試すと面白いと思います。setmaceのissueで去年のC4(UEFI BIOSセキュリティ)の講師のtandaさんを見つけて、みんな色々と勉強をしているんだなぁと思ったという小話を追記しておきます。

SRUDB.datは去年と同じく、srum-dumpを使って生成したxlsxファイルを使って解析しました。やはり、各プログラムの大まかな実行時間やPathやネットワーク使用履歴、ユーザーIDなどが確認できるのは非常に強力ですね。今回の講義では扱いませんでしたが、$UsnJrnl:$Jの情報も並行して利用するとより詳細な解析をすることができます。

prefetchファイルの解析はNirsoftのWinPrefetchViewを使って行いました。確か、$UsnJrnlの解析においてはこのpfファイルがCREATEされたりしたときにそのexeファイルが実行されたという解釈をしていた気がします。それはさて置き、このprefetchファイルの解析によってファイルシステム上から実体が消されていた怪しいファイルの存在が明らかになります。そのファイルのダウンロード元などを調べることでインシデントの全体の流れが見えてきます。ネットから拾ったファイルはexeファイルだけでなく、LNKファイルに関しても気をつけないといけませんね。

キーワード検索についてはAutopsy上でkeyword search ingest moduleを実行して行いました。pagefile.sysにはかなり有用な情報が残っているものですね。ここで受講者のAutopsyが起動しなくなるアクシデントが発生し、結局起動はできたもののingestの結果がなくなってしまっていて根本的な解決まで手助けできなかったことは残念でした。時間があったらingest後のindexの情報等をフォルダごとどうにかしてドライブ経由で送るなどの方法とれたかもしれませんが、多分講義時間中には厳しかった気がします。他にも、Autopsyはやはりメモリを食うので、VMが落ちてしまった等のアクシデントがたまに起こっていました。去年のC1でRedlineを使って怪しいプロセスを追って行った時も、VMのフリーズや動作が重い等の事象が発生していたので、フォレンジックの講義にはつきものかもしれません。

  • 事後の感想

今年のForensicsの講義も非常に充実した内容で面白かったです。チューターとしてもできる限りのサポートができたと思っています。CTF形式の演習は講義終了後に時間をとって、去年に引き続き今年も全完しました。もちろん$UsnJrnlとかは使わずに、今回の講義で扱った内容だけで全て解くことができたので、受講生の皆さんぜひチャレンジしてみてください。もっと時間が経ってから簡単なWriteupを書こうと今のところ考えています。その時に講義内容の詳細をもっと深掘りできるでしょう。

C1のCTF形式の演習

[C2C3] Kernel Exploit

(Advanced Linux Kernel Exploit)

  • 概要

eBPFの検証器のソースコードから脆弱性を見つけ、検証器のコード上で最大値と最小値の推測を間違えたレジスタを作り出すことで、ポインタをスカラーに変えてリークしたりALU sanitationを回避して範囲外書き込みを行ったりしてAAR/AAWを実現し、modprobe_pathもしくはcore_patternを書き換えてkernel landで任意のコードを実行する。

  • 事前準備

ptr-yudaiさんのこの講義は僕のチューター応募を最も後押ししたものです。いつかのタイミングでKernel Exploitに入門したいと考えていたので、今がちょうど良い時であると思いました。事前課題としてLinux Kernel Programmingという洋書を頂き、また、Pawnyableという素晴らしい資料サイトを閲覧することができました。Pawnyableは以下のリンクからご確認ください。

Linux Kernel Exploitation | PAWNYABLE!

チューターとして受講者のサポートができるほど精通しなければならないということで、Linux Kernel Programmingは9章まで読みました。この本もLinux Kernelのビルドからkernel moduleの作成、物理メモリをKernelがどのように扱っているのか、そしてKernelのAPIの違いなどをサンプルプログラムを動かすことで理解できる良書でした。UserlandのPwnに触れてきているため、ユーザープロセスから見たアドレス空間の理解はしていましたが、この本のおかげでKernelにおけるstackやheapなどLinux Kernelにとってのアドレス空間の全体図を把握することができました。もっとも、PawnyableにおいてExploitに必要なLinux Kernelの知識は尽くされているので、講義の準備のためにこの本をそこまで読み込む必要があったかと言われると、後述するeBPFの勉強の方をもっとやっておくべきだったという反省があります。C1の講義が終わった後にやばいやばいといいながらPawnyableのeBPFの章を読んでいました。

Pawnyableの方ではLinux Kernel Moduleの脆弱性をついた攻撃をサンプルの問題ともに学ぶことができました。全受講生がHolstein v1のexploitを書くことができていましたので、SMEP、SMAP、KPTI、KASLRなどKernel空間特有のセキュリティ機構のbypass方法を理解したことになります。

  • 講義内容

全然まとめられていないので、冬休みにちゃんとしたものを書こうと思いますが、ざっとだけ。 まず、eBPFについての導入と各命令について触れました。eBPFではkernel modeでユーザーが指定した機械語が実行されるので、やばい動作をしないよう検証器が存在しverifier.cで実装されています。今回はverifier.cの一部のコードにパッチが当てられており、ソースコードリーディングでこのパッチによってどのようなバグが生まれるかを特定するハンズオンを行いました。個人的には、ソースコードリーディングのためにbuildしていたlinuxのバージョンを間違えていてショックだったり、全然コードを読み進められなくてもっとコードを読み書きする必要性を感じたりしました。一方で、グループとしてはなんだかんだ議論しながら読んでいき、xorした結果の下32bitが定数だった時に検証器がレジスタの値の境界の条件である最大値と最小値の追跡を間違えている状態が作れる、という話に辿り着いていました。パッチやコードについて何も説明していないので、何が何だかわかりませんね。最終的にはAAR/AAWを実装して、modprobe_pathを書き換えて任意のコマンドを実行できるようになりましたが、その間にいくつもハードルがありました。また後日詳しい記事を書きたいと思います。

  • 事後の感想

内容の濃い講義でした。今後kernel exploitの問題にもチャレンジしていきたいです(と言いつつSECCONのbabypfに挑まずに寝てしまったんですが)。 受講生と通話しながらのハンズオンはとても良かったです。チームでCTFを解いている時と同じような体験で、1人でソースコードを読むよりやっぱり楽しいですね。

[C4C5] Goのソースコードの静的解析

(ソースコードから脆弱性を見つけよう)

  • 概要

Goのソースコードから得られた抽象構文木と型情報から、特定の挙動を検出する自前の静的解析ツールを作る。

  • 事前準備

この講義は事前にやっておくことは指定されていなかったので、何も準備せずに挑みました。とはいえ、GoのインストールやGOPATH, GOHOMEの設定などの準備、簡単にGoを書いてみる等は一応やっておくべきだったと思っています。

  • 講義内容

Cの他の講義と違い、プログラマー色の強い講義でした。自分はまともなプログラムを一切書いたことがなく、大変苦手とするところです。Goに関しても何も知っておらず、「Goのライブラリは全部ソースコードであってバイナリ形式ということはないんですか?」などといった初歩的な質問を現地でしていました。講義の頭では静的解析ツールを自作することの意義や、どこの部分を自作するのかといった導入がなされました。具体的には、ビルドして動かしてしまう前に静的解析をソースコードに対して行ってバグや脆弱性、あるいは悪意のあるコードを検出し対処することで修正のコストが下がるというメリットがあるようです。概要でも少し触れましたが、go/scanner, go/tokenによる字句解析や, go/parser, go/astといったgoのパッケージを使用して抽象構文木をゲットしたり、go/typesやgo/constantといったパッケージを使用して抽象構文木から型情報を抽出したりするなど、使える部分は既存のGoのパッケージの力を借りる一方で、得た抽象構文木や型情報に対して独自のルールチェックができるようなツールを作るというのが今回の講義のテーマでした。「ルールを作る場合はツールも作る。人の手でチェックはせず、ツールに任せることで検出漏れを防ぐ。」とのことです。この間に自分はGoのインストールやGOPATHの設定、goplsとcoc-goのインストールなどを裏で行っていました。講師のtenntennさんはlanguage serverの助けを借りずに生のvimでコーディングされていました。すごい。

ここからはいくつかのハンズオンに沿って講義内容を説明します。実際の講義ではソースコードの穴埋め(コード中にTODOと書いてある部分を実装する)という方法で行われましたが、一からコードを書く体で説明します。出来上がりのコードは講師のtenntennさんの解答例ですので、解答例に対する説明ということになります。ハンズオンのコードはgithubの以下のリンクにあります。

GitHub - gohandson/analysis-ja

section01 exercise01 unsafeパッケージの利用を検出

抽象構文木を使った解析のハンズオンです。くどいですが、「ソースコード中にunsafeというパッケージが使われているかどうか」というルールを作り、そのルールをチェックするツールを作るということですね。まずは使うパッケージをインポートし、main関数からrun関数を実行するようにし、runに処理の実体を書くことにします。run()の先頭で、Cでいうargv[1]の文字列の長さが0だったらソースコードの指定がないということでreturnするようにします。 今回はstrconv.Unquoteのためにstrconvパッケージをインポートしています。

main.goのテンプレート

package main

import (
  "errors"
  "fmt"
  "go/parser"
  "go/token"
  "os"
  "strconv"
)

func main() {
  if err := run(os.Args[1:]); err != nil {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
  }
}

func run(args []string) error {
  if len(args) < 1 {
    return errors.New("source code must be specified")
  }
  return nil
}

サブルーチン、runの中を書いていきます。抽象構文木を作るためにgo/parserのParseFile関数を使用します。ローカル変数fnameに解析対象のファイル名の文字列を格納します。go/tokenのNewFileSet関数で新たなtoken.FileSet構造体を作り、ローカル変数fsetにFileSet構造体のアドレスを格納します。fsetを第一引数に、fnameを第二引数に渡してParseFileを実行します。これによって解析対象のソースコードの抽象構文木のルートのノードである、ast.File構造体へとローカル変数fからアクセスできるようになります。

run

func run(args []string) error {
  if len(args) < 1 {
    return errors.New("source code must be specified")
  }

  fname := args[0]
  fset := token.NewFileSet()
  f, err := parser.ParseFile(fset, fname, nil, 0)
  if err != nil {
    return err
  }
  return nil
}

ast.File構造体を確認すると、File.Importsからast.ImportSpec構造体のポインタの配列にアクセスできると分かります。

ast.File

type File struct {
    Doc        *CommentGroup   // associated documentation; or nil
    Package    token.Pos       // position of "package" keyword
    Name       *Ident          // package name
    Decls      []Decl          // top-level declarations; or nil
    Scope      *Scope          // package scope (this file only)
    Imports    []*ImportSpec   // imports in this file
    Unresolved []*Ident        // unresolved identifiers in this file
    Comments   []*CommentGroup // list of all comments in the source file
}

ImportSpec.Pathからast.BasicLit構造体のポインタを手に入れることができ、BasicLit.Valueから文字列リテラルを得ることができます。

ast.ImportSpec

type ImportSpec struct {
    Doc     *CommentGroup // associated documentation; or nil
    Name    *Ident        // local package name (including "."); or nil
    Path    *BasicLit     // import path
    Comment *CommentGroup // line comments; or nil
    EndPos  token.Pos     // end of spec (overrides Path.Pos if nonzero)
}

ast.BasicLit

type BasicLit struct {
    ValuePos token.Pos   // literal position
    Kind     token.Token // token.INT, token.FLOAT, token.IMAG, token.CHAR, or token.STRING
    Value    string      // literal string; e.g. 42, 0x7f, 3.14, 1e-9, 2.4i, 'a', '\x7f', "foo" or `\m\n\o`
}

この文字列リテラルはGoのソースコードの中のimport()の中の各パッケージの名前の文字列リテラルです。よってこの文字列リテラルからstrconv.Unquoteを使って引用符をはずし、その文字列がunsafeと一致していれば検知することにすればよいです。Goでは宣言した変数を使わないとコンパイルエラーになるらしく、エラーハンドリングを省略するために例えば返り値にアンダースコアを使うといいらしいです(要出典)。

今回はfor range文のループカウンターは使わないのでアンダースコアに入れています。for range文の中でローカル変数specにf.Importsの指す配列の要素である各ast.ImportSpec構造体を格納しています。spec.Path.Valueから文字列リテラルを得られるので、その文字列リテラルをstrconv.Unquoteした結果をローカル変数pathに格納し、pathと文字列unsafeを比較しています。

go/astの構造体にはそれぞれPos()という関数が存在しているようで(後に分かるがこれは各構造体がPosというメソッドを使えると行った方が正しい表現だった)、これによってその構造体のtoken.Posが得られるようです。token.Posは「Pos is a compact encoding of a source position within a file set.」とのことで、ここからソースコードのなかの位置がわかります。さらにtoken.Posはtoken.Postion関数に渡すことでtoken.Position構造体に変換することができ、token.Position構造体から例えばunsafeパッケージはソースコードの何行目の何番にあるのかといった情報を得ることができます。今回はspec.Pos()によって得られたunsafeパッケージのImportSpec構造体のtoken.Posをfset.Positionに渡すことによってunsafeパッケージの位置を出力していますが、例えばspec.Path.Pos()からunsafeパッケージのBasicLit構造体のtoken.Posをゲットして、それを使っても同じ結果になることを確認しています。

run

func run(args []string) error {
  if len(args) < 1 {
    return errors.New("source code must be specified")
  }

  fname := args[0]
  fset := token.NewFileSet()
  f, err := parser.ParseFile(fset, fname, nil, 0)
  if err != nil {
    return err
  }
  for _, spec:= range f.Imports {
    path, err := strconv.Unquote(spec.Path.Value)
    if err != nil {
      return err
    }
    if path == "unsafe" {
      pos := fset.Position(spec.Pos())
      fmt.Fprintf(os.Stderr, "%s: import unsafe\n", pos)
    }
  }
  return nil
}

./testdata/a.go (解析対象)

package main

import (
        "fmt"
        "unsafe"
)

type T struct {
        X [2]string
        Y string
}

func main() {
        t := T{
                X: [...]string{"A", "B"},
                Y: "C",
        }

        xp := uintptr(unsafe.Pointer(&t.X))
        yp := (*string)(unsafe.Pointer(xp + unsafe.Sizeof("")*2))
        fmt.Println(*yp)
}

#実行結果
go build .
./exercise01 testdata/a.go
testdata/a.go:5:2: import unsafe

unsafeパッケージが使われていたら、検出できるようになりました。わいわい。今回はパッケージしか確認しないのでParseFileのmodeにparser.ImportsOnlyフラグを指定しても良かったですね。

section02 exercise01 init関数における不正なコマンドの呼び出しを検出

不正なコマンドとして、exec.Command関数の呼び出しをチェックしましょう。init関数はパッケージをインポートした時に実行される関数であり、もし不正なコマンドがinit関数に書かれているパッケージがあれば、そのパッケージをインポートするだけでコマンドが実行されてしまうので、静的解析の段階で検出して防ごうという意義のハンズオンだと思います。このハンズオンは大きく分けて2段階の実装をする必要があります。第一にinit関数を見つける機能、第二にexec.Command関数の呼び出しを見つける機能です。第二の段階では抽象構文木の走査だけではダメで、型チェックの力を借りる必要があります。というのもソースコード内でexec.Commandという文字列の構造を見つけたらOKというわけではなく、使われている文字列がなんであれ実体がos/execであるようなパッケージから、さらにCommandという名前の関数が実行されている場合こそが検出したい条件であるからです。そのためにはソースコードの各識別子がどこで定義し、どこで使用されているのかという情報と、各変数や式の型がなんであるかという情報、つまり型チェックから得られる情報が必要なのです。

型チェックのためにgo/importerとgo/typesをimportします。前と同じように、run関数の中に機能の主となる処理を書くことにします。

main.goのテンプレート

package main

import (
  "errors"
  "fmt"
  "go/ast"
  "go/importer"
  "go/parser"
  "go/token"
  "go/types"
  "os"
)

func main() {
  if err := run(os.Args[1:]); err != nil {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
  }
}

func run(args []string) error {
  if len(args) < 1 {
    return errors.New("source code must be specified")
  }
  return nil
}

run関数を書いていきましょう。token.NewFileSet関数によってローカル変数fsetを、そしてparser.ParseFile関数によってローカル変数fを宣言してソースコードの抽象構文木を作るところまでは同じです。

run

func run(args []string) error {
  if len(args) < 1 {
    return errors.New("source code must be specified")
  }

  fname := args[0]
  fset := token.NewFileSet()
  f, err := parser.ParseFile(fset, fname, nil, 0)
  if err != nil {
    return err
  }
  return nil
}

まずinit関数の宣言を探したいので、ast.FileのDeclsからast.Declインターフェースの配列にアクセスしましょう。

ast.File

type File struct {
    Doc        *CommentGroup   // associated documentation; or nil
    Package    token.Pos       // position of "package" keyword
    Name       *Ident          // package name
    Decls      []Decl          // top-level declarations; or nil
    Scope      *Scope          // package scope (this file only)
    Imports    []*ImportSpec   // imports in this file
    Unresolved []*Ident        // unresolved identifiers in this file
    Comments   []*CommentGroup // list of all comments in the source file
}

Goのインターフェースというものを正しく理解できている自信はありませんが、インターフェース内に定義されているメソッドが実装されているような構造体だけがそのインターフェースを使うことができるようです。

前回のハンズオンでImportSpec構造体のポインタであるローカル変数specに対してspec.Pos()を使うことができたのも、/usr/local/go/src/go/ast/ast.goの中にNodeというインターフェースが存在し(Nodeインターフェースはastの各構造体が使うことができるようで、ImportSpecのための特別なインターフェースではない)、Nodeインターフェース内のメソッドEnd()とPos()のImportSpec構造体用の実装が例えばfunc (s *ImportSpec) Pos() token.Pos {...}といったように存在しているので、ImportSpec構造体はNodeインターフェースを使うことができ、故にPosメソッドを使うことができたというのが正しい説明になるようです。また、Specというインターフェースが存在し、これはImportSpecに限らずValueSpecなどSpec系とされている構造体がつかえるようでありますが、Specインターフェース内のメソッドであるspecNode()のImportSpec構造体用の実装がfunc (*ImportSpec) specNode() {}としてast.go内に存在しているから、ImportSpec構造体はSpecインターフェースが使えることになります。そしてSpecインターフェースの中にNodeインターフェースが埋め込まれていて、これによってSpecインターフェースからNodeインターフェースのメソッドを使うこともできるようです。

最初は、全ての構造体が使えるNodeインターフェースをわざわざSpecインターフェースにも埋め込む意味が分かりませんでした、というのも、例えばSpecインターフェースを使う際、結局その中にはImportSpec等のNodeインターフェースを扱える構造体を代入しているわけですから。しかし、よくよく考えれば、もしSpecインターフェースにNodeインターフェースを埋め込んでいなかった場合、Specインターフェース自身には例えばPosメソッドは存在していないわけで、そのSpecインターフェースに対してPosメソッドを使いたい場合は一旦構造体のポインタに直してからPosメソッドを適用する必要に迫られて煩雑になるし不便なので、なるほど、インターフェースにインターフェースを埋め込むのは便利かもしれないと気づきました。

/usr/local/go/src/go/ast/ast.go

...
// All node types implement the Node interface.
type Node interface {
        Pos() token.Pos // position of first character belonging to the node
        End() token.Pos // position of first character immediately after the node
}
...
type (
        // The Spec type stands for any of *ImportSpec, *ValueSpec, and *TypeSpec.
        Spec interface {
                Node
                specNode()
        }

        // An ImportSpec node represents a single package import.
        ImportSpec struct {
                Doc     *CommentGroup // associated documentation; or nil
                Name    *Ident        // local package name (including "."); or nil
                Path    *BasicLit     // import path
                Comment *CommentGroup // line comments; or nil
                EndPos  token.Pos     // end of spec (overrides Path.Pos if nonzero)
        }
        ...
)
...
func (*ImportSpec) specNode() {}
...
// Pos and End implementations for spec nodes.

func (s *ImportSpec) Pos() token.Pos {
        if s.Name != nil {
                return s.Name.Pos()
        }
        return s.Path.Pos()
}
...
func (s *ImportSpec) End() token.Pos {
        if s.EndPos != 0 {
                return s.EndPos
        }
        return s.Path.End()
}
...

さて本題にもどると、ast.DeclインターフェースはdeclNode()というメソッドをもっていて、declNode()のメソッドの実装を探してみると、BadDecl構造体、GenDecl構造体、FuncDecl構造体の三つがこのインターフェースを使えるようです。実際のコードはgo/parserのparser.goなどを見てほしいですが、parser.ParseFile関数の中の実装を確認すると、go/parserの内部の構造体であるparser構造体をinitメソッドで初期化した後に、parseFile()というメソッドを呼んでいます。parseFileメソッドはast.File構造体へのポインタを返すので当然ast.Fileを作る処理があり、そこでは例えばparser構造体のparseGenDeclメソッド等で返されたGenDecl構造体のポインタがappendでast.Declの配列に追加されていっています。重要なことは、ast.File.DeclsにはBadDecl, GenDecl, FuncDecl構造体のポインタが入っており、今回探したいのはinit関数であるので、FuncDecl構造体のポインタのみにとりあえず限定する必要があるということです。

ast.Decl

...
// All declaration nodes implement the Decl interface.
type Decl interface {
        Node
        declNode()
}
...
// declNode() ensures that only declaration nodes can be
// assigned to a Decl.
func (*BadDecl) declNode()  {}
func (*GenDecl) declNode()  {}
func (*FuncDecl) declNode() {}
...

そこで使えるものとして、Goのインターフェースには型アサーションという機能があるようです。この型アサーションはインターフェースの中身の型を限定してインターフェースの中身を返すもので、もしその型にすることができなかったら初期値のnilを返す機能のようです。また、二つ目の返り値としてtrue(うまくいった)もしくはfalseが返されるようです。今回はf.Declの各ポインタにたいして型アサーションでFuncDecl構造体のポインタのみを抜き出してしまいましょう。型アサーション後のローカル変数declの中身がnilだったら型アサーション失敗ということで、FuncDecl構造体のポインタではないということになります。

init関数には加えていくつかの特徴があります。まずは、名前の文字列がinitであること。これはast.FuncDecl.Nameであるast.Ident構造体のNameメンバの文字列リテラルがinitであることを意味します。ほかには、関数であってメソッドではない、つまり、ast.FuncDecl.Recvがnilであることが必要です。さらに、外部(つまり、Goではない)関数ではない、要するに、ast.FuncDecl.Bodyがnilでないということになります。これらをコードの中に落とし込んで、その条件に合致した場合のみ、そのFuncDeclの中の関数呼び出しの解析をすることにしましょう。関数呼び出しの解析はfindCallCommand関数に実装することにします。

run

func run(args []string) error {
  if len(args) < 1 {
    return errors.New("source code must be specified")
  }

  fname := args[0]
  fset := token.NewFileSet()
  f, err := parser.ParseFile(fset, fname, nil, 0)
  if err != nil {
    return err
  }
  for _, decl := range f.Decls {
    decl, _ := decl.(*ast.FuncDecl)
    if decl == nil || decl.Recv != nil || decl.Name.Name != "init" || decl.Body == nil {
      continue
    }
    findCallCommand()
  }
  return nil
}

init関数にはたどり着けるようになったので、次はexec.Command()をいかにして検出するかという話になります。そのためには型チェックの力を使う必要があるので、そのための準備をしてしまいましょう。型チェックはgo/typesのtypes.Config構造体のCheckメソッドを使います。types.Config構造体を作る必要がありますが、その際にConfig.Importerを指定する必要があり、go/importerのimporter.Default()を使うことにします。あまり、よく調べていませんが、Importerによって解析対象のソースコードがインポートしたパッケージのパスを解決するらしいです。Checkメソッドによる型チェックの結果を格納するtypes.Info構造体も作成します。types.Info構造体はmap型の変数の数々で構成されています。GoのmapはいわゆるDictionaryで、keyとvalueの組み合わせを保存しています。go/astの構造体のポインタやinterfaceをkeyへ、go/typesの構造体のポインタをvalueへという形でそれぞれを対応づけており、astからtypesへのアクセスを可能にするもののようですね。makeを使ってmapを作ってしまいましょう。今回の解析対象のファイルであるlib.goのパッケージ名はlibですので、第一引数にはlibを入れてconfig.Checkを呼びます。

types.Config

type Config struct {
    // Context is the context used for resolving global identifiers. If nil, the
    // type checker will initialize this field with a newly created context.
    Context *Context

    // GoVersion describes the accepted Go language version. The string
    // must follow the format "go%d.%d" (e.g. "go1.12") or it must be
    // empty; an empty string indicates the latest language version.
    // If the format is invalid, invoking the type checker will cause a
    // panic.
    GoVersion string

    // If IgnoreFuncBodies is set, function bodies are not
    // type-checked.
    IgnoreFuncBodies bool

    // If FakeImportC is set, `import "C"` (for packages requiring Cgo)
    // declares an empty "C" package and errors are omitted for qualified
    // identifiers referring to package C (which won't find an object).
    // This feature is intended for the standard library cmd/api tool.
    //
    // Caution: Effects may be unpredictable due to follow-on errors.
    //          Do not use casually!
    FakeImportC bool

    // If Error != nil, it is called with each error found
    // during type checking; err has dynamic type Error.
    // Secondary errors (for instance, to enumerate all types
    // involved in an invalid recursive type declaration) have
    // error strings that start with a '\t' character.
    // If Error == nil, type-checking stops with the first
    // error found.
    Error func(err error)

    // An importer is used to import packages referred to from
    // import declarations.
    // If the installed importer implements ImporterFrom, the type
    // checker calls ImportFrom instead of Import.
    // The type checker reports an error if an importer is needed
    // but none was installed.
    Importer Importer

    // If Sizes != nil, it provides the sizing functions for package unsafe.
    // Otherwise SizesFor("gc", "amd64") is used instead.
    Sizes Sizes

    // If DisableUnusedImportCheck is set, packages are not checked
    // for unused imports.
    DisableUnusedImportCheck bool
    // contains filtered or unexported fields
}

types.Info

type Info struct {
    // Types maps expressions to their types, and for constant
    // expressions, also their values. Invalid expressions are
    // omitted.
    //
    // For (possibly parenthesized) identifiers denoting built-in
    // functions, the recorded signatures are call-site specific:
    // if the call result is not a constant, the recorded type is
    // an argument-specific signature. Otherwise, the recorded type
    // is invalid.
    //
    // The Types map does not record the type of every identifier,
    // only those that appear where an arbitrary expression is
    // permitted. For instance, the identifier f in a selector
    // expression x.f is found only in the Selections map, the
    // identifier z in a variable declaration 'var z int' is found
    // only in the Defs map, and identifiers denoting packages in
    // qualified identifiers are collected in the Uses map.
    Types map[ast.Expr]TypeAndValue

    // Instances maps identifiers denoting generic types or functions to their
    // type arguments and instantiated type.
    //
    // For example, Instances will map the identifier for 'T' in the type
    // instantiation T[int, string] to the type arguments [int, string] and
    // resulting instantiated *Named type. Given a generic function
    // func F[A any](A), Instances will map the identifier for 'F' in the call
    // expression F(int(1)) to the inferred type arguments [int], and resulting
    // instantiated *Signature.
    //
    // Invariant: Instantiating Uses[id].Type() with Instances[id].TypeArgs
    // results in an equivalent of Instances[id].Type.
    Instances map[*ast.Ident]Instance

    // Defs maps identifiers to the objects they define (including
    // package names, dots "." of dot-imports, and blank "_" identifiers).
    // For identifiers that do not denote objects (e.g., the package name
    // in package clauses, or symbolic variables t in t := x.(type) of
    // type switch headers), the corresponding objects are nil.
    //
    // For an embedded field, Defs returns the field *Var it defines.
    //
    // Invariant: Defs[id] == nil || Defs[id].Pos() == id.Pos()
    Defs map[*ast.Ident]Object

    // Uses maps identifiers to the objects they denote.
    //
    // For an embedded field, Uses returns the *TypeName it denotes.
    //
    // Invariant: Uses[id].Pos() != id.Pos()
    Uses map[*ast.Ident]Object

    // Implicits maps nodes to their implicitly declared objects, if any.
    // The following node and object types may appear:
    //
    //     node               declared object
    //
    //     *ast.ImportSpec    *PkgName for imports without renames
    //     *ast.CaseClause    type-specific *Var for each type switch case clause (incl. default)
    //     *ast.Field         anonymous parameter *Var (incl. unnamed results)
    //
    Implicits map[ast.Node]Object

    // Selections maps selector expressions (excluding qualified identifiers)
    // to their corresponding selections.
    Selections map[*ast.SelectorExpr]*Selection

    // Scopes maps ast.Nodes to the scopes they define. Package scopes are not
    // associated with a specific node but with all files belonging to a package.
    // Thus, the package scope can be found in the type-checked Package object.
    // Scopes nest, with the Universe scope being the outermost scope, enclosing
    // the package scope, which contains (one or more) files scopes, which enclose
    // function scopes which in turn enclose statement and function literal scopes.
    // Note that even though package-level functions are declared in the package
    // scope, the function scopes are embedded in the file scope of the file
    // containing the function declaration.
    //
    // The following node types may appear in Scopes:
    //
    //     *ast.File
    //     *ast.FuncType
    //     *ast.TypeSpec
    //     *ast.BlockStmt
    //     *ast.IfStmt
    //     *ast.SwitchStmt
    //     *ast.TypeSwitchStmt
    //     *ast.CaseClause
    //     *ast.CommClause
    //     *ast.ForStmt
    //     *ast.RangeStmt
    //
    Scopes map[ast.Node]*Scope

    // InitOrder is the list of package-level initializers in the order in which
    // they must be executed. Initializers referring to variables related by an
    // initialization dependency appear in topological order, the others appear
    // in source order. Variables without an initialization expression do not
    // appear in this list.
    InitOrder []*Initializer
}

また、os/execのexec.Commandメソッドの実体かどうかを特定できるよう、exec.Commandのオブジェクトを取得しておきましょう。パッケージが違うなど、スコープが違えばオブジェクトも違ってくるので、os/execパッケージのスコープからCommandメソッドのオブジェクトを探すことになります。ここで得られたオブジェクトの情報は、後にinit関数のBody部分の抽象構文木を探索して得るオブジェクトの情報との比較に使うことになります。

型情報として現在手元にあるのは、types.Package構造体のポインタであるローカル変数pkgとtypes.Info構造体のポインタであるローカル変数infoです。まず、os/execパッケージのtypes.Package構造体を取得するために、pkg.Importsメソッドによってpkg.importsメンバ、つまりインポートしているtypes.Package構造体の配列を取得し、それをfor rangeで回します。もしあるtypes.Package構造体のPathメソッドの返り値の文字列がos/execと一致していれば、os/execパッケージのtypes.Package構造体を取得できたということで、さらにScopeメソッドでos/execパッケージのtypes.Scope構造体のポインタを取得し、Lookupメソッドでそのtypes.Scope構造体の中のCommandオブジェクトを取得します。これでfincCallCommand関数の実装に入る準備ができましたね。

run

func run(args []string) error {
  if len(args) < 1 {
    return errors.New("source code must be specified")
  }

  fname := args[0]
  fset := token.NewFileSet()
  f, err := parser.ParseFile(fset, fname, nil, 0)
  if err != nil {
    return err
  }

  config := &types.Config{
    Importer: importer.Default(),
  }

  info := &types.Info{
    Types: make(map[ast.Expr]types.TypeAndValue),
    Instances: make(map[*ast.Ident]types.Instance),
    Defs: make(map[*ast.Ident]types.Object),
    Uses: make(map[*ast.Ident]types.Object),
    Implicits: make(map[ast.Node]types.Object),
    Selections: make(map[*ast.SelectorExpr]*types.Selection),
    Scopes: make(map[ast.Node]*types.Scope),
    InitOrder: []*types.Initializer{},
  }

  pkg, err := config.Check("lib", fset, []*ast.File{f}, info)
  if err != nil {
    return err
  }

  var execCommand types.Object
  for _, p:= range pkg.Imports() {
    if p.Path() == "os/exec" {
      execCommand = p.Scope().Lookup("Command")
      break
    }
  }

  for _, decl := range f.Decls {
    decl, _ := decl.(*ast.FuncDecl)
    if decl == nil || decl.Recv != nil || decl.Name.Name != "init" || decl.Body == nil {
      continue
    }
    findCallCommand()
  }
  return nil
}

types.Package

// A Package describes a Go package.
type Package struct {
        path     string
        name     string
        scope    *Scope
        complete bool
        imports  []*Package
        fake     bool // scope lookup errors are silently dropped if package is fake (internal use only)
        cgo      bool // uses of this package will be rewritten into uses of declarations from _cgo_gotypes.go
}

types.Scope

// A Scope maintains a set of objects and links to its containing
// (parent) and contained (children) scopes. Objects may be inserted
// and looked up by name. The zero value for Scope is a ready-to-use
// empty scope.
type Scope struct {
        parent   *Scope
        children []*Scope
        number   int               // parent.children[number-1] is this scope; 0 if there is no parent
        elems    map[string]Object // lazily allocated
        pos, end token.Pos         // scope extent; may be invalid
        comment  string            // for debugging only
        isFunc   bool              // set if this is a function scope (internal use only)
}

findCallCommand関数が必要とする情報は、ソースコード内の位置情報を出力するtoken.FileSet.Positionメソッドを使うためのローカル変数fset、型情報を保持しているtypes.Info構造体であるローカル変数info、os/exec.Commandのオブジェクトであるローカル変数execCommand、第一段階の実装で見つけたinit関数の中の処理に相当する抽象構文木のrootノードであるdecl.Bodyの4つです。これらをそれぞれ引数で渡し、以降はfindCallCommand関数の実装を行います。

run

func run(args []string) error {
  if len(args) < 1 {
    return errors.New("source code must be specified")
  }

  fname := args[0]
  fset := token.NewFileSet()
  f, err := parser.ParseFile(fset, fname, nil, 0)
  if err != nil {
    return err
  }

  config := &types.Config{
    Importer: importer.Default(),
  }

  info := &types.Info{
    Types: make(map[ast.Expr]types.TypeAndValue),
    Instances: make(map[*ast.Ident]types.Instance),
    Defs: make(map[*ast.Ident]types.Object),
    Uses: make(map[*ast.Ident]types.Object),
    Implicits: make(map[ast.Node]types.Object),
    Selections: make(map[*ast.SelectorExpr]*types.Selection),
    Scopes: make(map[ast.Node]*types.Scope),
    InitOrder: []*types.Initializer{},
  }

  pkg, err := config.Check("lib", fset, []*ast.File{f}, info)
  if err != nil {
    return err
  }

  var execCommand types.Object
  for _, p:= range pkg.Imports() {
    if p.Path() == "os/exec" {
      execCommand = p.Scope().Lookup("Command")
      break
    }
  }

  for _, decl := range f.Decls {
    decl, _ := decl.(*ast.FuncDecl)
    if decl == nil || decl.Recv != nil || decl.Name.Name != "init" || decl.Body == nil {
      continue
    }
    findCallCommand(fset, info, execCommand, decl.Body)
  }
  return nil
}

init関数のBody部分にどれほどネストが存在するのかは解析対象依存ですので、Body部分の抽象構文木全体のノードを探索します。抽象構文木の探索にはast.Inspect関数を使います。ast.Inspect関数は第一引数に渡されたノードをルートとして抽象構文木深さ優先探索を行い、各ノードに対して第二引数に渡した関数を使用します。個人的には関数自体を関数の引数に渡すのは初めての経験で、Goはこんなこともできるんだなぁという気持ちです。より正確にはGoには関数型という型があって、ast.Inspectの第二引数には関数型の変数が渡されているっぽいです。合っているかな。

ast.Inspectは/usr/local/go/src/go/ast/walk.goに宣言されています。ast.Inspectの中ではast.Walk関数が呼ばれており、Walk関数の第一引数にはinspector(f)が入っています。inspectorはfunc(Node) boolという関数型の型であり、最初は何をしているのかよく分からなかったのですが、ast.Inspectorの第二引数をinspector型にcastしているようです。Goのcast文はこうやって書くんですね。また、Visitorというインターフェイスが用意されていて、VisitorにはVisitというメソッドがありますが、inspector型用のVisitメソッドの実装が存在するのでinspector型はVisitorインターフェースを使用することができます。inspector型のVisitメソッドは、受け取ったnodeに対してそのinspector型の関数を実行します。そしてその返り値がtrueの場合はif文の中に入ってメソッドを使った自身であるinspector型をreturnし、falseだった場合はnilが返ります。例えばv := inspector(func(n ast.Node) bool {return true})というinspector型の変数vがあるとして、v.Visit(node)というようにVisitメソッドを使うと、vの関数(関数型の変数にたいしてその関数を呼称するときに関数型変数の関数という呼び方をして大丈夫なのかどうかは僕は分かりません)であるfunc()がnodeを引数にとって実行されてtrueを返すのでVisitメソッド内のif文の中に入り、vをreturnします。

ast.Walkは第一引数にVisitorインターフェースを、第二引数にast.Nodeを取ります。そしてまず最初にそのNodeに対してVisitメソッドを呼び出し、返ってきた値(VisitメソッドはVisitorインターフェースを返す)がnilだったらリターンし、もしnilでなかったらNodeに対して型アサーションを実行します。switch n := node.(type) {case *Comment: hoge; case *Package: hugaのようにswitch文でast.Nodeの各型によって場合分けをして、そのNodeに子ノードが存在していればその子ノードに対してast.Walkを実行します。

/usr/local/go/src/go/ast/walk.go

...
// A Visitor's Visit method is invoked for each node encountered by Walk.
// If the result visitor w is not nil, Walk visits each of the children
// of node with the visitor w, followed by a call of w.Visit(nil).
type Visitor interface {
    Visit(node Node) (w Visitor)
}
...
func Walk(v Visitor, node Node) {
    if v = v.Visit(node); v == nil {
        return
    }

    // walk children
    // (the order of the cases matches the order
    // of the corresponding node types in ast.go)
    switch n := node.(type) {
    // Comments and fields
        case:
    default:
        panic(fmt.Sprintf("ast.Walk: unexpected node type %T", n))
    }

    v.Visit(nil)
}
type inspector func(Node) bool

func (f inspector) Visit(node Node) Visitor {
    if f(node) {
        return f
    }
    return nil
}

// Inspect traverses an AST in depth-first order: It starts by calling
// f(node); node must not be nil. If f returns true, Inspect invokes f
// recursively for each of the non-nil children of node, followed by a
// call of f(nil).
func Inspect(node Node, f func(Node) bool) {
    Walk(inspector(f), node)
}

ここまでをまとめて実際に分かっていればよいことは、深さ優先探索というアルゴリズムに則って、抽象構文木のルートとして設定したast.Nodeから順に各ノードにたいしてast.Inspectの第二引数に渡されたユーザー定義のinspector型の関数を実行するが、その関数がfalseを返す特別な場合には現在のノードの子ノードから先に対しては探索を行わないということです。よってやるべきことは各ノードに対してexecCommandと一致するtypes.ObjectをCallしているかどうかを判定するinspector型の関数を実装してast.Inspectの第二引数に渡すことになります。

まず、exec.Commandは関数呼び出しなので、型アサーションを各ノードに対して行ってast.CallExprへのポインタであるかどうかを確認します。*ast.CallExprでない場合は探索を続けるのでその時点でtrueを返します。次に、ノードがast.CallExprであったときの更なるチェックを行います。exec.Commandはメソッドであり、ast.CallExpr構造体のFunメンバであるExprインターフェースの中身はast.SelectorExprであるはずです。これも型アサーションを行い、*ast.SelectorExprでなかった場合はtrueを返し探索を続行するようにします。githubの解答例ではfalseを返していますが、その場合はメソッドでない関数呼び出しの中の抽象構文木より先は探索を行わないので、例えばfunc check(cmd *myexec.Cmd) *myexec.Cmd {return cmd}のようなラッパー関数みたいなものを作ってinit関数内でcheck(myexec.Command("ls")を実行する、といったようにメソッドでない関数呼び出しの引数の中でexec.Commandを実行された場合は検出漏れしてしまいますのでtrueを返すようにしました。勿論講義の中では解答例のコードは完璧ではなく、例えばexec.Commandを一旦test := exec.Commandのように変数に代入してからtest("ls")を実行するといった方法を使われると回避されてしまう、などという説明がありました。

さて、以上のチェックをくぐり抜けてきたノードはメソッドと言えるのでこのast.SelectorExpr.Selからメソッドのast.Identを取得します。このast.Identに対応するtypes.Objectがexec.Commandのtypes.Objectと一致するかを比較し、一致していれば出力するようにすれば、検出できたということになります。一致していなければ別のメソッドということになるので、trueを返して探索を続けます。ast.Infoによってastの各構造体にtypesの各構造体が対応付けられているという話をしましたが、今回のようにast.Ident構造体からtypes.Object構造体のポインタをゲットしたい場合はtypes.Info.Objectofというメソッドがあります。このメソッドは引数に渡されたast.Identのポインタに対応するtypes.Objectのポインタをtypes.Info構造体のDefsメンバとUsesメンバのmapから探して返してくれます。

findCallCommand

func findCallCommand(fset *token.FileSet, info *types.Info, execCommand types.Object, root ast.Node) {
  ast.Inspect(root, func(n ast.Node) bool {
    call, _ := n.(*ast.CallExpr)
    if call == nil {
      return true
    }

    sel, _ := call.Fun.(*ast.SelectorExpr)
    if sel == nil {
      return true
    }

    fun := info.ObjectOf(sel.Sel)
    if fun == execCommand {
      pos := fset.Position(n.Pos())
      fmt.Fprintf(os.Stdout, "%s: find exec.Command in init\n", pos)
      return false
    }
    return true
  })
}

ast.CallExpr

type CallExpr struct {
    Fun      Expr      // function expression
    Lparen   token.Pos // position of "("
    Args     []Expr    // function arguments; or nil
    Ellipsis token.Pos // position of "..." (token.NoPos if there is no "...")
    Rparen   token.Pos // position of ")"
}

ast.SelectorExpr
type SelectorExpr struct { X Expr // expression Sel *Ident // field selector }

types.ObjectOf()

// ObjectOf returns the object denoted by the specified id,
// or nil if not found.
//
// If id is an embedded struct field, ObjectOf returns the field (*Var)
// it defines, not the type (*TypeName) it uses.
//
// Precondition: the Uses and Defs maps are populated.
func (info *Info) ObjectOf(id *ast.Ident) Object {
        if obj := info.Defs[id]; obj != nil {
                return obj
        }
        return info.Uses[id]
}

これにて実装は終了です。最後に全体のコードを確認して実行してみましょう。

main.go

package main

import (
  "errors"
  "fmt"
  "go/ast"
  "go/importer"
  "go/parser"
  "go/token"
  "go/types"
  "os"
)

func main() {
  if err := run(os.Args[1:]); err != nil {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
  }
}

func run(args []string) error {
  if len(args) < 1 {
    return errors.New("source code must be specified")
  }

  fname := args[0]
  fset := token.NewFileSet()
  f, err := parser.ParseFile(fset, fname, nil, 0)
  if err != nil {
    return err
  }

  config := &types.Config{
    Importer: importer.Default(),
  }

  info := &types.Info{
    Types: make(map[ast.Expr]types.TypeAndValue),
    Instances: make(map[*ast.Ident]types.Instance),
    Defs: make(map[*ast.Ident]types.Object),
    Uses: make(map[*ast.Ident]types.Object),
    Implicits: make(map[ast.Node]types.Object),
    Selections: make(map[*ast.SelectorExpr]*types.Selection),
    Scopes: make(map[ast.Node]*types.Scope),
    InitOrder: []*types.Initializer{},
  }

  pkg, err := config.Check("lib", fset, []*ast.File{f}, info)
  if err != nil {
    return err
  }

  var execCommand types.Object
  for _, p:= range pkg.Imports() {
    if p.Path() == "os/exec" {
      execCommand = p.Scope().Lookup("Command")
      break
    }
  }

  for _, decl := range f.Decls {
    decl, _ := decl.(*ast.FuncDecl)
    if decl == nil || decl.Recv != nil || decl.Name.Name != "init" || decl.Body == nil {
      continue
    }
    findCallCommand(fset, info, execCommand, decl.Body)
  }
  return nil
}

func findCallCommand(fset *token.FileSet, info *types.Info, execCommand types.Object, root ast.Node) {
  ast.Inspect(root, func(n ast.Node) bool {
    call, _ := n.(*ast.CallExpr)
    if call == nil {
      return true
    }

    sel, _ := call.Fun.(*ast.SelectorExpr)
    if sel == nil {
      return true
    }

    fun := info.ObjectOf(sel.Sel)
    if fun == execCommand {
      pos := fset.Position(n.Pos())
      fmt.Fprintf(os.Stdout, "%s: find exec.Command in init\n", pos)
      return false
    }
    return true
  })
}

./testdata/lib/lib.go (解析対象)

package lib

import (
        "os"
        myexec "os/exec"
)

var Message = "hello"

func init() {
        cmd := myexec.Command("ls")
        cmd.Stdout = os.Stdout
        _ = cmd.Run()
}

#実行結果
go build .
./exercise01 testdata/lib/lib.go
testdata/lib/lib.go:11:9: find exec.Command in init

ちゃんと動いていますね。

  • 事後の感想

チューターとしては、Goのインストールの質問が来た時にGOHOMEとGOPATHの設定をチャットに書いたくらいであまりできることはありませんでしたので、このブログでそれなりに詳しく復習することで仕事をしたということにします。各ハンズオンの復習でかなりgo/astやgo/typesの中のコードをチェックしたのですが、Goの色々な機能を知ることもできました。インターフェースとかキャストとか何も知らない状態でGoのコードを読むのは「いったいこれは何なのだろう」から始まって中々大変だったので、まずは言語のDocumentationを読んで大まかな書き方を把握してからソースコードを読むようにした方が良かったかもと思いつつも、僕の場合は結局なにもしないままであることが多いので、こういう初めて触れる言語のソースコードを読まなければいけない状況を用意してもらえるのはありがたい経験でした。攻撃用以外のプログラムを書くことへの苦手意識をすこし克服できたと思っています。完全なるGo初心者かつプログラミング初心者が書いた内容ですので、用語の使い方とかで間違っている部分があったり、もっと良い説明の仕方があったりする場合は指摘をして頂けると嬉しいです。

[C6C7] Malware Analysis

(Real World Malware Analysis: Dissecting Stealthy Vector)

  • 概要

ghidraとghidra scriptを使用して、実際のマルウェアに対する静的解析を行い、暗号アルゴリズムの特定やコンフィグの取得方法を学ぶ。

  • 事前準備

ghidraのinstallが必要でしたが、特段問題はなかったと思います。マルウェア解析は自分があまり精進できていない部分になります。CTFのRevの問題は中級の入り口レベルのものは解けるようになりましたが、でかいプログラムを解析するのはどうにも途中で投げ出しがちで。それでも、それなりのサポートができるとは思っていましたが、結果から言うとまだまだでしたね。

  • 講義内容

僕は去年のC3-1, C3-2に同じ講義(扱うマルウェアは違う)を受けましたが、訳あって録画受講だったので、実際に受講生でディスカッション形式で行うのは初めてでした。やはり、実際に他の人と試行錯誤をしながらハンズオンに取り組むというのは、非常に効果が高いですね。実際のマルウェアの解析を扱っており、decompile後の結果とはいえ実際のコードを何でもかんでも載せていいのかどうか匙加減がわからないので、「こういうことを学んだよ」という説明を主に書きます。まだ完全な復習が終わっていないというのもありますが、Writeupは慎重に書いたものを後でアップできたらと思っています。

講義の導入部分ではマルウェアの種類やMITREのATT&CKの特にマルウェア周りの攻撃シチュエーションの話がありました。DLL-Side LoadingとかProcess Hollowingとか、僕はまだ何も実際に確認していないんだなぁと気づかされました。Mandiantのcapaというツールに関する話もあり、これは後で使います。

次にマルウェア解析の全体の流れの説明がありました。表層解析、動的解析、静的解析の順にお手軽ではなくなり、それぞれ長所と短所がありますが、その中でも静的解析は幅広い知識が要求され、かつ、時間もかかるので大変ですよというお話でした。

PE FormatやWindows APIにもざっくり触れました。末尾がAとWの違いで、A(asciiの方)はW(UTF-16の方)のapiのラッパーだとかそういったところです。僕はいまだにWin API特有の型名に慣れていないらしい。Ghidraの各ウィンドウの機能も確認して、いざ一つ目の擬似マルウェア(41154F3という文字列が入っていたりするように講師の方が講習目的で作成されたもの)の解析に入ります。

エントリーポイントから見ていくTop Down解析と文字列やAPIから辿るBottom Up的な解析がありますが、今回はTop Down解析の入り口、WinMainを見つけるところからです。Symbol treeのentry関数から辿っていったら見つかりますね。exeファイルとして同じ拡張子を持っていても、コンパイラや使っている言語が違えばエントリーポイントの各関数や構造は全く異なっていた記憶があるので、特にSymbol情報がStrippedなバイナリで余計な場所の解析に時間を取られないよう、この手順を確認することは意味があることだと思います。

次にcapaを使いました。このツールはそのバイナリの中にある機能を表示してくれるものです。詳細な解析に入る前に、機械的な処理だけでざっとマルウェアの特徴を掴むことができます。capaの限界として、ImportテーブルのAPIやプログラム中の文字列等をもとにルールで検知しているので、難読化に弱いようです。APIの難読化として、たとえばLoadLibrary等のAPIでわざわざ実行時にDLL等をロードして、GetProcAddressを使ってそのDLL内のAPIへのアクセスを提供するといった方法があります。

お次はマルウェアのコンフィグの抽出です。一部のローカル変数(特に隣接している)がWinMainの内部で執拗に使いまわされていたら、マルウェアの動作を管理している構造体である可能性が高く、コンフィグとして抽出するメリットがあります。このコンフィグはマルウェアの挙動を理解しやすくなるだけでなく、ただ一部のパラメータを弄っただけの同じファミリーのマルウェアに対しても有用であり、また、各マルウェアをファミリーに分類するための情報源の一つとなります。C4C5の講義でもそうですし、Linux KernelのExploitの勉強をしていてもそうですが、色々なところで構造体を使ったプログラムの実装があるんだなと日々実感しており、マルウェア開発している人々も、当然構造体を使っているんでしょうね。

コンフィグらしき構造体を初期化している処理を確認して、ghidraのauto_struct機能を使ってCONFIG構造体のメンバやそのメンバの型などを特定したりしました。初期化の処理ではバイナリに埋め込まれたデータをxor等で復号する処理があり、ここに対してはghidra scriptsを書いて実行することで自動化するという演習がありました。こういったハンズオンはまっさらな状態から自分の手であれこれやるから意味があって、まさにseccampは最適な環境だと再認識しました。CONFIG構造体のメンバ最終的に何のAPIに渡されているかをチェックすると、各メンバの機能がわかってきます。他にも、stack上にレジストリの文字列を作ることで文字列ベースの検出回避しようとするコードがあって、一方でその程度の難読化はcapaがそれをちゃんと検知しており、create or open registry keycontain obfuscated stackstrings等のタグ付けがされていました。総じてマルウェア解析の導入に相応しい擬似マルウェアの解析のハンズオンだったと思います。

後半は実際のマルウェアの解析演習です。これは、後で時間を取って詳細なwriteupを書くのでざっと説明します。

DllMainとServiceMainが存在することから、DLLがWindowsサービスとして実行されるタイプのマルウェアです。マルウェアの中から三つほどの大きめのコードブロックを抽出し、そのブロックの動作を追うことでマルウェアの全貌を把握するといった流れの解析を行いました。

一つ目のコードブロックではある関数が大量に呼び出されており、その関数の返り値がそれぞれ配列に格納されていました。関数の動作の目的を知るには入力(引数)と出力(返り値、あるいは操作結果を格納する構造体)にフォーカスして調べることが有効で、今回の関数はGetProcAddressというWin APIの結果が返されるものであったので、api解決の関数であろうということでresolve_apiという関数名をghidra上で割り振るといった感じに進めていきます。

また、resolve_api内部では第一引数に渡されたバイト列は第二引数と共にとある関数に渡されており、その関数によって変更されたバイト列をGetModuleHandleAというapiに渡すことでGetProcAddressのためのhModuleを用意しています。この関数はとある暗号アルゴリズムを使って意味のある文字列を復号していると考えられ、この暗号アルゴリズムを特定しようというハンズオンがありました。受講生全体を二つに分けて各グループにチューター1人という形で解析を進めていったんですが、自分の方では暗号アルゴリズムを特定できませんでした(個別にできていた受講生はいたかもしれません)。暗号アルゴリズムの特定にはcapaのコメントを参考にするほか、暗号アルゴリズムに使われていそうな定数の値を検索する方法も有効で、今回はその定数で検索するとRFC7539のChaCha20がヒットします。capaのコメントではrc4等書いてあったりするので、コメントは参考にはしつつも鵜呑みにしてはいけないという例でした。気軽に検索する癖をつけないといけませんね。

次にghidra scriptを用いてresolve_apiでどのapiを呼び出しているかわかりやすくしようというハンズオンがありました。ChaCha20のdecryptをするghidra scriptを書いてどのapiを呼び出しているのかを特定するという一つ目のステップと、多数の変数を手でrenameなんてしたくないので、各apiの名前のフィールドをもつ構造体をCのヘッダーファイルの形で定義して、復号するためのバイト列を格納している配列へその構造体を適用することで一気にresolve_apiの第一引数のバイト列が各apiの名前として表示されるようにするという二つ目のステップがあり、なかなかに大変でしたね。

ここまで書いていて思ったんですが、この関数だとかその構造体だとか、対応するコードが示されていないとなんだかよくわかりませんね。実際にはこの後も、configもChaCha20で復号されてから使われているからそのdecryptのghidra scriptを書こう、等の複数のハンズオンがあり、また、マルウェアの挙動としてもEtwEventWriteの多分エントリーポイントを機械語片で書き換えて無力化したりとか面白い内容があって、受講生とああでもないこうでもないと一緒になって解析を進めたわけですが、今回はここまでにして未来の自分が書くwriteupに任せようと思います。

  • 事後の感想

マルウェア解析に挑む人として、学ぶものは多かったです。というか、去年の録画受講では逃していたエッセンスを今回補うことができたという感じです。特にもっと時間に追われながら、解析に臨むべきですね。そうしないと、重要なとこだけサッと理解する力が身につかない。また、Cryptoにノータッチな状態ではありますが、マルウェアの難読化やパックに使われがちな暗号アルゴリズムくらいはサラッと学ぶべきだと気付かされました。

チューターとしても、もっとマルウェアと戯れている状態であったなら、より多くの有効なアドバイスや小話を挟めたりして、受講生により良い学びの機会を提供できていたのではないかという思いがあります。

まとめ

C2C3, C6C7等中途半端な記事を出してしまって申し訳ないです。10月から大学に復学してアップアップしており、そんな中で何も出せていないことが余計重石になっていたのでとりあえずブログを出すことには出したという気持ちです。writeupは完全理解した未来の自分がきっと書いてくれるはず。C1は受講生から問題のwriteup欲しいよって声があったら、あるいは、1ヶ月くらいたったら書いて出そうと思います。

後は、seccampで受講生の皆さんがかなりCTFに興味を持っているということで、TSGのyoutubeチャンネル東京大学TSG - YouTubeでpwnの導入動画を出そうと思っています。これは僕がsig-beginners-pwnという名前で2回ほどTSG内で開催したもので、ここに書くからには(編集とかのクオリティはお察しですが)ちゃんと出したいと思っていますが、嘘になったらごめん。チャンネル登録をしてもらえるとやる気が出るのでお願いします。少なくとも今年の駒場祭でまたLive CTF等があると思うのでよろしくお願いします。