🐍

[Python] パスワード付きのZipファイルを無理やり開く

2022/05/19に公開

はじめに

ハードディスクの整理をしていたら謎の.zipファイルを発見しました。
結構な容量があるので何か大事なものだったのかもしれませんが、パスワードで保護されていて開くことが出来ませんでした。
そこでPythonと7-Zipを使い、パスワードを総当たりで無理やり開くことにしました。

環境

python
Python 3.10.4 (tags/v3.10.4:9d38120, Mar 23 2022, 23:13:41) [MSC v.1929 64 bit (AMD64)]
7z
7-Zip 21.07 (x64) : Copyright (c) 1999-2021 Igor Pavlov : 2021-12-26

コード

コード
openZip.py
import os
import os.path
import itertools
import subprocess
import argparse
import re
import json

import Decorator

Zip = r"C:\Program Files\7-Zip\7z.exe"
ASCII = r""" !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~"""
Error1 = "Cannot open the file as archive"

class Settings:

  def __init__(self, archive="", progress="", chars="", minLength=0, maxLength=0, start=0):
    self.archive = archive
    self.progress = progress
    self.chars = chars
    self.minLength = minLength
    self.maxLength = maxLength
    self.start = start

def joinIter(iter):
  return "".join(map(str, iter))

# list(map(joinIter, itertools.product(string, repeat=n)))
def getString(i, length, chars=ASCII, cl=0):
  if cl < 1:
    cl = len(chars)
  q = i
  string = ""
  for _ in itertools.repeat(None, length):
    q, r = divmod(q, cl)
    string += chars[r]
  return string

def extract(archive, password):
  # -p{password}だと "などが入力されると挙動がおかしくなることがあるので"-sccUTF-8"を設定してsubprocessのinputでパスワードを渡している
  result = subprocess.run([Zip, "t", archive, "-y", "-sccUTF-8"], input=password, encoding="utf-8", stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  if Error1 in result.stderr:
    raise ValueError(result.stderr)
  return result.returncode

def saveProgress(settings, password):
  with open(settings.progress, "w", encoding="utf-8") as file:
    st = settings.__dict__
    del st["progress"]
    st["lastWord"] = password
    if st["start"] > 0:
      st["start"] -= 1
    json.dump(st, file, indent=2)

def loadProgress(args):
  with open(args.progress, "r", encoding="utf-8") as file:
    result = json.load(file)
  if result["archive"] != args.archive or result["chars"] != args.chars:
    error = f"""Setting is Mismatch\n{result["archive"]}\n{args.archive}\n{result["chars"]}\n{args.chars}"""
    raise ValueError(error)
  del result["lastWord"]
  result["progress"] = args.progress
  return Settings(**result)

def getArgs(args):
  if args.cont and os.path.exists(args.progress):
    return loadProgress(args)
  else:
    return Settings(args.archive, args.progress, args.chars, args.min, args.max, 0)

@Decorator.stopwatchPrintSE
def searchPassword(settings):
  try:
    charsLength = len(settings.chars)
    for length in range(settings.minLength, settings.maxLength + 1):
      end = charsLength**length
      formatLength = len(str(end))
      print(f"Password length={length}, trials={end}: {settings.chars} ({charsLength})")
      for i in range(settings.start, end):
        password = getString(i, length, settings.chars)
        result = extract(settings.archive, password)
        print(f"\r{i:>{formatLength+1}}: {password:>{length+1}} : ret={result}", end="")
        if result == 0:
          print(f"\nPassword is {password}")
          return "success"
      print("\n")
    return "unknown"
  except KeyboardInterrupt:
    print("\n")
    settings.minLength = length
    settings.start = i
    saveProgress(settings, password)
    return "interrupt"
    
def argumentParser():
  parser = argparse.ArgumentParser()
  parser.add_argument("archive", help="archive file")
  parser.add_argument("-c", "--cont", action="store_true", help="continue")
  parser.add_argument("-p", "--progress", default="", help="path to save progress if interrupted.")
  parser.add_argument("-chars", default=ASCII, help="charcters")
  parser.add_argument("-min", type=int, default=1, help="minimum length")
  parser.add_argument("-max", type=int, default=8, help="maximum length")
  parser.add_argument("-a", "--showArgument", action="store_true", help="show arguments.")
  args = parser.parse_args()
  args.archive = os.path.abspath(args.archive)
  if args.progress == "":
    args.progress = os.path.splitext(args.archive)[0] + ".json"
  else:
    args.progress = os.path.abspath(args.progress)
  return args
  
if __name__ == "__main__":
  args = argumentParser()
  if args.showArgument:
    print(args)

  if not os.path.exists(args.archive):
    print(f"File not found. {args.archive}")
    exit(1)

  settings = getArgs(args)
  result = searchPassword(settings)
  if result != "interrupt" and os.path.exists(args.progress):
    os.remove(args.progress)

パスワードを試すと言うよりかZipファイルを開ける為に、7-Zipをsubprocess経由で呼び出しています。
7-Zipにコマンドラインからパスワードを渡すのに-pコマンドオプションがあります。
-p経由で渡すと、"などが含まれているパスワードを上手く渡せない事があります。
その為、以下のようにパスワードを標準入力(input)経由で渡しています。
そして、念のために7-Zipにコンソールからの入力をUTF-8として認識させるように、
-sccUTF-8コマンドオプションを設定しています。

subprocess.run([Zip, "t", archive, "-y", "-sccUTF-8"], input=password, encoding="utf-8", stdout=subprocess.PIPE, stderr=subprocess.PIP)

簡単な使い方

コードのファイル名を"openZip.py"とした場合は、
py openZip.py "archiveFileName.zip"
など実行すると時間がかかりますがZipファイルを開くことが出来ます。

残念ながら、庭に草が生えるぐらい遅いのでCtrl-C(KeyboardInterrupt)で中断した場合は、そこから再開できるようにしています。
-pで中断ファイルのパスを指定していない場合は、自動でarchiveFileName.jsonとアーカイブファイル名と同じJSONファイルが作成されます。
自動で作成されたファイルを使って再開したい場合は、
py openZip.py "archiveFileName.zip" -c
とすれば、再開できます。
明示的に中断ファイルを指定したければ、
py openZip.py "archiveFileName.zip" -p "archiveFileName.json" -c
などとします。

-charsオプションは、パスワードに含まれる(試行する)文字を制限したい場合に指定します。
例えば、
-chars "abcdefghijklmnopqrstuvwxyz"
とするとパスワードとして小文字のアルファベットの組み合わせしか試しません。

-minオプションは、試行するパスワードの最小文字数です。
-maxオプションは、試行するパスワードの最大文字数です。
例えば、3~4文字の間を試したい場合は、
py openZip.py "archiveFileName.zip" -min 3 -max 4
と指定します


参考

https://sevenzip.osdn.jp/chm/cmdline/commands/index.htm

余談

PyPyとCPythonの実行速度比較

PyPyを使ったことがなかったので、このコードを使って雑に速度比較をしてみました。
PyPy: 1118.677 sec.
CPython: 503.840 sec.
と圧倒的にCPythonの方が高速でした。
PyPyで明らかに処理が速くなるものも有るんですがね。

軽く検証した所、PyPyが遅い一番の原因は、subprocessの実行が原因のようです。
subprocessの呼び出しをしないテストコードを書いてみると、両者ともに圧倒的に速くなり、若干PyPyが遅いぐらいになります。
つまり、PyPyは、subprocessの実行に倍ぐらい時間が掛かるようです。

一般的にPyPyは、関数呼び出しが遅いといわれているようです。
関数呼び出しを無くすと両者ともに速くなるのですが、PyPyの方がより速くなりました。

結局、このコードを高速化するには、処理の肝であるsubprocessを何とかするしかないようです。
マルチスレッドやマルチプロセスに対応させるのが良いと思いますが、それはまた別の機会があれば試してみるつもりです。

pypy
searchPassword: 21:49:58
Password length=1, trials=26: abcdefghijklmnopqrstuvwxyz (26)
 25:  z : ret=2

Password length=2, trials=676: abcdefghijklmnopqrstuvwxyz (26)
 675:  zz : ret=2

Password length=3, trials=17576: abcdefghijklmnopqrstuvwxyz (26)
 12846:  cat : ret=0
Password is cat
searchPassword: 22:08:36(1118.677 sec.)
searchPassword: 22:45:01
Password length=1, trials=26: abcdefghijklmnopqrstuvwxyz (26)
 25:  z : ret=2

Password length=2, trials=676: abcdefghijklmnopqrstuvwxyz (26)
 675:  zz : ret=2

Password length=3, trials=17576: abcdefghijklmnopqrstuvwxyz (26)
 12846:  cat : ret=0
Password is cat
searchPassword: 22:53:25(503.840 sec.)

別のモジュールを試してみる

subprocessが遅いのならば、モジュールを使って改善できないかと検証してみました。

zipfile

Pythonの標準ライブラリです。
".zip"でなおかつ暗号化方式が"ZipCrypto"がならばこれで十分でした。
速度もびっくりするぐらい速いです。
(というよりsubprocessが遅すぎるのでしょうが)
下記の様に、extract()を置き換えれば動作します。
ただし、以下の欠点で採用しませんでした。

欠点は、".7z"などに対応しないことと暗号化形式が"AES-256"ならば開けない事です。
そして、zipfileの使い方を何か間違っている可能性がありますが、設定したパスワード以外で開けてしまう事がある事です。
これは、圧縮した時に、空のファイルを圧縮したのが原因かもしれませんが

openZip.py
import zipfile

def extract(path, password):
  with zipfile.ZipFile(path) as archive:
    try:
      archive.extractall(pwd=password.encode("utf-8"))
      return 0
    except Exception as e:
      # print(e)
      return 1

py7zr

検索して出てきたので試してみましたが、適当に使った限りだとsubprocessよりも遅くなりました。
また、パスワードを試しているうちに例外が発生してしまいます。
ドキュメント通りに使っているはずなのですが、原因が不明です。
原因を特定できれば良かったのですが、遅くなるのでは意味がないので採用を見送りました。
そもそも開くだけならばこれで十分なのですが、今回の要件とは合いませんでした。
".7z"などに対応できるのは、魅力的なんですがね。

patool

ソースを見た限り内部でsubprocessで7zipを呼んでいるようなので採用はしませんでした。

rarfile

".rar"用で".zip"では使えないようなので、不採用です。

Discussion