softcloak 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- softcloak-1.0.0/PKG-INFO +68 -0
- softcloak-1.0.0/README.md +59 -0
- softcloak-1.0.0/pyproject.toml +29 -0
- softcloak-1.0.0/setup.cfg +4 -0
- softcloak-1.0.0/src/softcloak/__init__.py +6 -0
- softcloak-1.0.0/src/softcloak/__main__.py +4 -0
- softcloak-1.0.0/src/softcloak/cli.py +122 -0
- softcloak-1.0.0/src/softcloak/package.py +209 -0
- softcloak-1.0.0/src/softcloak/rewrite.py +300 -0
- softcloak-1.0.0/src/softcloak.egg-info/PKG-INFO +68 -0
- softcloak-1.0.0/src/softcloak.egg-info/SOURCES.txt +15 -0
- softcloak-1.0.0/src/softcloak.egg-info/dependency_links.txt +1 -0
- softcloak-1.0.0/src/softcloak.egg-info/entry_points.txt +2 -0
- softcloak-1.0.0/src/softcloak.egg-info/requires.txt +3 -0
- softcloak-1.0.0/src/softcloak.egg-info/top_level.txt +1 -0
- softcloak-1.0.0/tests/test_rewrite.py +150 -0
- softcloak-1.0.0/tests/test_ruff.py +31 -0
softcloak-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: softcloak
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Replace strings in an APK and re-sign it.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: ruff>=0.16; extra == "dev"
|
|
9
|
+
|
|
10
|
+
# SoftCloak
|
|
11
|
+
|
|
12
|
+
The goal of this repository is to try and make reverse engineering more difficult. Currently only
|
|
13
|
+
focused on APK files but will eventually move on to IPA files, binaries, firmware, and other software.
|
|
14
|
+
|
|
15
|
+
SoftCloak replaces text inside an APK, then signs the rebuilt package. Substring matches are swapped for one generated token of the same length. Exact matches are swapped for text you choose. The APK is signed with a new jar signature.
|
|
16
|
+
|
|
17
|
+
Same-length replacements keep the DEX, manifest, and resource table aligned, so the file can still be installed.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
A JDK is required. `keytool` and `jarsigner` must be on `PATH`.
|
|
22
|
+
|
|
23
|
+
From the project directory:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install -e ".[dev]"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The `dev` extra installs Ruff. The test suite runs `ruff check` and `ruff format --check`.
|
|
30
|
+
|
|
31
|
+
## Use
|
|
32
|
+
|
|
33
|
+
Generate a token for every occurrence of a substring:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
softcloak app.apk -o rebuilt.apk --contains com.example --contains CompanyName
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Replace one exact string with your own text. The new text has to be the same length:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
softcloak app.apk -o rebuilt.apk --exact "Old full sentence" "New full sentence"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Both can be combined. Exact replacements run first, then substring tokens. The command prints each mapping and checks the signature before it finishes.
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
softcloak app.apk -o rebuilt.apk --dry-run --contains CompanyName
|
|
49
|
+
softcloak app.apk -o rebuilt.apk --exact "abcdef" "xy" --fit pad
|
|
50
|
+
softcloak app.apk -o rebuilt.apk --contains CompanyName --keystore my.keystore --storepass secret --alias mykey
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`--fit pad` fills a shorter exact replacement. `--fit clip` cuts a longer one and pads a shorter one. The default is to stop when the lengths differ.
|
|
54
|
+
|
|
55
|
+
`--contains` replaces that text wherever it sits inside a longer string, in every file, in both UTF-8 and UTF-16. A dotted name also rewrites the DEX form with slashes, so `com.example` changes `Lcom/example/...` as well. Matching is case-sensitive. Pass `AppName` and `appname` separately. Patterns shorter than 4 characters are allowed, but they can hit unrelated bytes.
|
|
56
|
+
|
|
57
|
+
Image, audio, font, and native-library entries are left unchanged. Signature files are removed and replaced by the new signature. Other `META-INF` files are kept and edited.
|
|
58
|
+
|
|
59
|
+
## What can break the app
|
|
60
|
+
|
|
61
|
+
SoftCloak changes only the text you pass in. A same-length rename of your own package or class name can still install and run, because the manifest and `classes.dex` are updated together.
|
|
62
|
+
|
|
63
|
+
The app can crash or fail a lookup when a replacement has to match something that was not changed:
|
|
64
|
+
|
|
65
|
+
- Framework names such as `android` or `java` appear inside system class names. Replacing them makes those calls invalid. A short warning exists for patterns under 4 characters, and it does not stop the run. `android` is long enough to pass through with no warning.
|
|
66
|
+
- Resource paths, such as `res/layout/...`, must match the file names in the APK. SoftCloak does not rename those files.
|
|
67
|
+
- Native libraries are left unchanged. If code now looks up a new name and the library still has the old one, that call fails.
|
|
68
|
+
- Intent actions, content-provider authorities, and server URLs fail when only one side of the match is replaced.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# SoftCloak
|
|
2
|
+
|
|
3
|
+
The goal of this repository is to try and make reverse engineering more difficult. Currently only
|
|
4
|
+
focused on APK files but will eventually move on to IPA files, binaries, firmware, and other software.
|
|
5
|
+
|
|
6
|
+
SoftCloak replaces text inside an APK, then signs the rebuilt package. Substring matches are swapped for one generated token of the same length. Exact matches are swapped for text you choose. The APK is signed with a new jar signature.
|
|
7
|
+
|
|
8
|
+
Same-length replacements keep the DEX, manifest, and resource table aligned, so the file can still be installed.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
A JDK is required. `keytool` and `jarsigner` must be on `PATH`.
|
|
13
|
+
|
|
14
|
+
From the project directory:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install -e ".[dev]"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The `dev` extra installs Ruff. The test suite runs `ruff check` and `ruff format --check`.
|
|
21
|
+
|
|
22
|
+
## Use
|
|
23
|
+
|
|
24
|
+
Generate a token for every occurrence of a substring:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
softcloak app.apk -o rebuilt.apk --contains com.example --contains CompanyName
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Replace one exact string with your own text. The new text has to be the same length:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
softcloak app.apk -o rebuilt.apk --exact "Old full sentence" "New full sentence"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Both can be combined. Exact replacements run first, then substring tokens. The command prints each mapping and checks the signature before it finishes.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
softcloak app.apk -o rebuilt.apk --dry-run --contains CompanyName
|
|
40
|
+
softcloak app.apk -o rebuilt.apk --exact "abcdef" "xy" --fit pad
|
|
41
|
+
softcloak app.apk -o rebuilt.apk --contains CompanyName --keystore my.keystore --storepass secret --alias mykey
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`--fit pad` fills a shorter exact replacement. `--fit clip` cuts a longer one and pads a shorter one. The default is to stop when the lengths differ.
|
|
45
|
+
|
|
46
|
+
`--contains` replaces that text wherever it sits inside a longer string, in every file, in both UTF-8 and UTF-16. A dotted name also rewrites the DEX form with slashes, so `com.example` changes `Lcom/example/...` as well. Matching is case-sensitive. Pass `AppName` and `appname` separately. Patterns shorter than 4 characters are allowed, but they can hit unrelated bytes.
|
|
47
|
+
|
|
48
|
+
Image, audio, font, and native-library entries are left unchanged. Signature files are removed and replaced by the new signature. Other `META-INF` files are kept and edited.
|
|
49
|
+
|
|
50
|
+
## What can break the app
|
|
51
|
+
|
|
52
|
+
SoftCloak changes only the text you pass in. A same-length rename of your own package or class name can still install and run, because the manifest and `classes.dex` are updated together.
|
|
53
|
+
|
|
54
|
+
The app can crash or fail a lookup when a replacement has to match something that was not changed:
|
|
55
|
+
|
|
56
|
+
- Framework names such as `android` or `java` appear inside system class names. Replacing them makes those calls invalid. A short warning exists for patterns under 4 characters, and it does not stop the run. `android` is long enough to pass through with no warning.
|
|
57
|
+
- Resource paths, such as `res/layout/...`, must match the file names in the APK. SoftCloak does not rename those files.
|
|
58
|
+
- Native libraries are left unchanged. If code now looks up a new name and the library still has the old one, that call fails.
|
|
59
|
+
- Intent actions, content-provider authorities, and server URLs fail when only one side of the match is replaced.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "softcloak"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Replace strings in an APK and re-sign it."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = []
|
|
8
|
+
|
|
9
|
+
[project.optional-dependencies]
|
|
10
|
+
dev = ["ruff>=0.16"]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
softcloak = "softcloak.cli:main"
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["setuptools>=61"]
|
|
17
|
+
build-backend = "setuptools.build_meta"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools]
|
|
20
|
+
package-dir = {"" = "src"}
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = ["src"]
|
|
24
|
+
include = ["softcloak*"]
|
|
25
|
+
|
|
26
|
+
[tool.ruff]
|
|
27
|
+
target-version = "py310"
|
|
28
|
+
src = ["src", "tests"]
|
|
29
|
+
extend-exclude = ["*.egg-info"]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Command line for SoftCloak."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from softcloak.rewrite import rewrite_apk
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _parser() -> argparse.ArgumentParser:
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
prog="softcloak",
|
|
14
|
+
description=(
|
|
15
|
+
"Replace substrings in an APK with a same-length obfuscated token, "
|
|
16
|
+
"replace exact strings with text you choose, and re-sign the APK."
|
|
17
|
+
),
|
|
18
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
19
|
+
epilog=(
|
|
20
|
+
"examples:\n"
|
|
21
|
+
" softcloak app.apk -o rebuilt.apk --contains com.example --contains CompanyName\n"
|
|
22
|
+
' softcloak app.apk -o rebuilt.apk --exact "Old full sentence" "New full sentence"\n'
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument("apk", help="input APK")
|
|
26
|
+
parser.add_argument("-o", "--output", required=True, help="signed output APK")
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--contains",
|
|
29
|
+
action="append",
|
|
30
|
+
default=[],
|
|
31
|
+
metavar="SUBSTRING",
|
|
32
|
+
help="replace every occurrence of SUBSTRING with one generated token of the same length",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--exact",
|
|
36
|
+
nargs=2,
|
|
37
|
+
action="append",
|
|
38
|
+
default=[],
|
|
39
|
+
metavar=("OLD", "NEW"),
|
|
40
|
+
help="replace the exact text OLD with NEW. NEW must be the same length unless --fit is set",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument("--seed", default="softcloak", help="seed for generated tokens")
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--fit",
|
|
45
|
+
choices=("error", "pad", "clip"),
|
|
46
|
+
default="error",
|
|
47
|
+
help="what to do when an exact replacement has a different length (default: error)",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--pad-char", default=" ", help="character used by --fit pad or --fit clip"
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--allow-missing",
|
|
54
|
+
action="store_true",
|
|
55
|
+
help="do not fail when a search string is absent",
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--dry-run",
|
|
59
|
+
action="store_true",
|
|
60
|
+
help="report replacements without writing an APK",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--keystore",
|
|
64
|
+
help="existing keystore. A temporary key is generated when omitted",
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument("--storepass", help="keystore password")
|
|
67
|
+
parser.add_argument("--keypass", help="key password. Defaults to --storepass")
|
|
68
|
+
parser.add_argument("--alias", default="repack", help="key alias")
|
|
69
|
+
return parser
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _print_result(result) -> None:
|
|
73
|
+
for rule in result.rules:
|
|
74
|
+
utf8_count, utf16_count = result.counts.get(rule.old, (0, 0))
|
|
75
|
+
extras = [form for form in rule.forms if form != rule.old]
|
|
76
|
+
suffix = f" also {extras!r}" if extras else ""
|
|
77
|
+
print(
|
|
78
|
+
f"{rule.kind} {rule.old!r} -> {rule.new!r}{suffix} "
|
|
79
|
+
f"(utf8={utf8_count}, utf16={utf16_count})"
|
|
80
|
+
)
|
|
81
|
+
for warning in result.warnings:
|
|
82
|
+
print(f"warning: {warning}", file=sys.stderr)
|
|
83
|
+
if result.dry_run:
|
|
84
|
+
print("dry run: no APK written")
|
|
85
|
+
else:
|
|
86
|
+
print(f"wrote {result.output}")
|
|
87
|
+
print("signature verified")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main(argv: list[str] | None = None) -> int:
|
|
91
|
+
args = _parser().parse_args(argv)
|
|
92
|
+
for text in args.contains:
|
|
93
|
+
if len(text) < 4:
|
|
94
|
+
print(
|
|
95
|
+
f"warning: {text!r} is very short and can match unrelated bytes",
|
|
96
|
+
file=sys.stderr,
|
|
97
|
+
)
|
|
98
|
+
try:
|
|
99
|
+
result = rewrite_apk(
|
|
100
|
+
args.apk,
|
|
101
|
+
args.output,
|
|
102
|
+
contains=args.contains,
|
|
103
|
+
exact=[tuple(pair) for pair in args.exact],
|
|
104
|
+
seed=args.seed,
|
|
105
|
+
fit=args.fit,
|
|
106
|
+
pad_char=args.pad_char,
|
|
107
|
+
allow_missing=args.allow_missing,
|
|
108
|
+
dry_run=args.dry_run,
|
|
109
|
+
keystore=args.keystore,
|
|
110
|
+
storepass=args.storepass,
|
|
111
|
+
keypass=args.keypass,
|
|
112
|
+
alias=args.alias,
|
|
113
|
+
)
|
|
114
|
+
except (ValueError, RuntimeError) as exc:
|
|
115
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
116
|
+
return 1
|
|
117
|
+
_print_result(result)
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__":
|
|
122
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Build an aligned APK and sign it with jarsigner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import struct
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
import zipfile
|
|
11
|
+
import zlib
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
SIGNATURE_SUFFIXES = (".SF", ".RSA", ".DSA", ".EC")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_signature_entry(name: str) -> bool:
|
|
18
|
+
if not name.upper().startswith("META-INF/"):
|
|
19
|
+
return False
|
|
20
|
+
base = name.rsplit("/", 1)[-1].upper()
|
|
21
|
+
return (
|
|
22
|
+
base == "MANIFEST.MF"
|
|
23
|
+
or base.startswith("SIG-")
|
|
24
|
+
or base.endswith(SIGNATURE_SUFFIXES)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def find_java_tool(name: str) -> str:
|
|
29
|
+
found = shutil.which(name)
|
|
30
|
+
if found:
|
|
31
|
+
return found
|
|
32
|
+
java_home = os.environ.get("JAVA_HOME")
|
|
33
|
+
if java_home:
|
|
34
|
+
suffix = ".exe" if os.name == "nt" else ""
|
|
35
|
+
candidate = Path(java_home) / "bin" / f"{name}{suffix}"
|
|
36
|
+
if candidate.is_file():
|
|
37
|
+
return str(candidate)
|
|
38
|
+
raise RuntimeError(f"Could not find {name}. Install a JDK and put it on PATH.")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _deflate(data: bytes) -> bytes:
|
|
42
|
+
compressor = zlib.compressobj(9, zlib.DEFLATED, -15)
|
|
43
|
+
return compressor.compress(data) + compressor.flush()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_zip(entries: list[tuple[zipfile.ZipInfo, bytes]], dest: Path) -> None:
|
|
47
|
+
blob = bytearray()
|
|
48
|
+
central = bytearray()
|
|
49
|
+
for info, data in entries:
|
|
50
|
+
name_b = info.filename.encode("utf-8")
|
|
51
|
+
method = (
|
|
52
|
+
zipfile.ZIP_STORED
|
|
53
|
+
if info.compress_type == zipfile.ZIP_STORED
|
|
54
|
+
else zipfile.ZIP_DEFLATED
|
|
55
|
+
)
|
|
56
|
+
crc = zlib.crc32(data) & 0xFFFFFFFF
|
|
57
|
+
if method == zipfile.ZIP_STORED:
|
|
58
|
+
payload = data
|
|
59
|
+
pad = (4 - ((len(blob) + 30 + len(name_b)) % 4)) % 4
|
|
60
|
+
extra = b"\x00" * pad
|
|
61
|
+
else:
|
|
62
|
+
payload = _deflate(data)
|
|
63
|
+
extra = b""
|
|
64
|
+
date_time = info.date_time or (2026, 10, 12, 0, 0, 0)
|
|
65
|
+
dos_time = (date_time[3] << 11) | (date_time[4] << 5) | (date_time[5] // 2)
|
|
66
|
+
dos_date = ((date_time[0] - 1980) << 9) | (date_time[1] << 5) | date_time[2]
|
|
67
|
+
local_off = len(blob)
|
|
68
|
+
blob += struct.pack(
|
|
69
|
+
"<IHHHHHIIIHH",
|
|
70
|
+
0x04034B50,
|
|
71
|
+
20,
|
|
72
|
+
0,
|
|
73
|
+
method,
|
|
74
|
+
dos_time,
|
|
75
|
+
dos_date,
|
|
76
|
+
crc,
|
|
77
|
+
len(payload),
|
|
78
|
+
len(data),
|
|
79
|
+
len(name_b),
|
|
80
|
+
len(extra),
|
|
81
|
+
)
|
|
82
|
+
blob += name_b + extra + payload
|
|
83
|
+
if (
|
|
84
|
+
method == zipfile.ZIP_STORED
|
|
85
|
+
and (local_off + 30 + len(name_b) + len(extra)) % 4 != 0
|
|
86
|
+
):
|
|
87
|
+
raise RuntimeError(f"failed to align {info.filename}")
|
|
88
|
+
central += struct.pack(
|
|
89
|
+
"<IHHHHHHIIIHHHHHII",
|
|
90
|
+
0x02014B50,
|
|
91
|
+
20,
|
|
92
|
+
20,
|
|
93
|
+
0,
|
|
94
|
+
method,
|
|
95
|
+
dos_time,
|
|
96
|
+
dos_date,
|
|
97
|
+
crc,
|
|
98
|
+
len(payload),
|
|
99
|
+
len(data),
|
|
100
|
+
len(name_b),
|
|
101
|
+
len(extra),
|
|
102
|
+
0,
|
|
103
|
+
0,
|
|
104
|
+
0,
|
|
105
|
+
0,
|
|
106
|
+
local_off,
|
|
107
|
+
)
|
|
108
|
+
central += name_b + extra
|
|
109
|
+
cd_off = len(blob)
|
|
110
|
+
blob += central
|
|
111
|
+
blob += struct.pack(
|
|
112
|
+
"<IHHHHIIH",
|
|
113
|
+
0x06054B50,
|
|
114
|
+
0,
|
|
115
|
+
0,
|
|
116
|
+
len(entries),
|
|
117
|
+
len(entries),
|
|
118
|
+
len(central),
|
|
119
|
+
cd_off,
|
|
120
|
+
0,
|
|
121
|
+
)
|
|
122
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
dest.write_bytes(blob)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _run(command: list[str]) -> None:
|
|
127
|
+
result = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
128
|
+
if result.returncode != 0:
|
|
129
|
+
detail = (result.stderr or result.stdout or "").strip()
|
|
130
|
+
raise RuntimeError(f"{command[0]} failed: {detail}")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def sign_apk(
|
|
134
|
+
unsigned: Path,
|
|
135
|
+
dest: Path,
|
|
136
|
+
*,
|
|
137
|
+
keystore: Path | None = None,
|
|
138
|
+
storepass: str | None = None,
|
|
139
|
+
keypass: str | None = None,
|
|
140
|
+
alias: str = "repack",
|
|
141
|
+
) -> None:
|
|
142
|
+
keytool = find_java_tool("keytool")
|
|
143
|
+
jarsigner = find_java_tool("jarsigner")
|
|
144
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
145
|
+
temp = Path(temp_dir)
|
|
146
|
+
owned_keystore = keystore is None
|
|
147
|
+
if owned_keystore:
|
|
148
|
+
keystore = temp / "repack.keystore"
|
|
149
|
+
storepass = "android"
|
|
150
|
+
keypass = "android"
|
|
151
|
+
_run(
|
|
152
|
+
[
|
|
153
|
+
keytool,
|
|
154
|
+
"-genkeypair",
|
|
155
|
+
"-keystore",
|
|
156
|
+
str(keystore),
|
|
157
|
+
"-storepass",
|
|
158
|
+
storepass,
|
|
159
|
+
"-keypass",
|
|
160
|
+
keypass,
|
|
161
|
+
"-alias",
|
|
162
|
+
alias,
|
|
163
|
+
"-keyalg",
|
|
164
|
+
"RSA",
|
|
165
|
+
"-keysize",
|
|
166
|
+
"2048",
|
|
167
|
+
"-validity",
|
|
168
|
+
"10000",
|
|
169
|
+
"-dname",
|
|
170
|
+
"CN=SoftCloak, OU=Local, O=Local, C=US",
|
|
171
|
+
]
|
|
172
|
+
)
|
|
173
|
+
if storepass is None:
|
|
174
|
+
raise RuntimeError("storepass is required when keystore is provided")
|
|
175
|
+
keypass = keypass or storepass
|
|
176
|
+
signed = temp / "signed.apk"
|
|
177
|
+
_run(
|
|
178
|
+
[
|
|
179
|
+
jarsigner,
|
|
180
|
+
"-sigalg",
|
|
181
|
+
"SHA256withRSA",
|
|
182
|
+
"-digestalg",
|
|
183
|
+
"SHA-256",
|
|
184
|
+
"-keystore",
|
|
185
|
+
str(keystore),
|
|
186
|
+
"-storepass",
|
|
187
|
+
storepass,
|
|
188
|
+
"-keypass",
|
|
189
|
+
keypass,
|
|
190
|
+
"-signedjar",
|
|
191
|
+
str(signed),
|
|
192
|
+
str(unsigned),
|
|
193
|
+
alias,
|
|
194
|
+
]
|
|
195
|
+
)
|
|
196
|
+
with zipfile.ZipFile(signed) as archive:
|
|
197
|
+
entries = [
|
|
198
|
+
(info, archive.read(info.filename)) for info in archive.infolist()
|
|
199
|
+
]
|
|
200
|
+
build_zip(entries, dest)
|
|
201
|
+
result = subprocess.run(
|
|
202
|
+
[jarsigner, "-verify", str(dest)],
|
|
203
|
+
capture_output=True,
|
|
204
|
+
text=True,
|
|
205
|
+
check=False,
|
|
206
|
+
)
|
|
207
|
+
if result.returncode != 0:
|
|
208
|
+
detail = (result.stderr or result.stdout or "").strip()
|
|
209
|
+
raise RuntimeError(f"signed APK failed verification: {detail}")
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""In-place APK string replacement that keeps binary lengths stable."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import struct
|
|
8
|
+
import tempfile
|
|
9
|
+
import zipfile
|
|
10
|
+
import zlib
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from softcloak.package import build_zip, is_signature_entry, sign_apk
|
|
15
|
+
|
|
16
|
+
SKIP_SUFFIXES = {
|
|
17
|
+
".png",
|
|
18
|
+
".jpg",
|
|
19
|
+
".jpeg",
|
|
20
|
+
".webp",
|
|
21
|
+
".gif",
|
|
22
|
+
".so",
|
|
23
|
+
".mp3",
|
|
24
|
+
".ogg",
|
|
25
|
+
".wav",
|
|
26
|
+
".ttf",
|
|
27
|
+
".otf",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Rule:
|
|
33
|
+
kind: str
|
|
34
|
+
old: str
|
|
35
|
+
new: str
|
|
36
|
+
forms: tuple[str, ...] = ()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class RewriteResult:
|
|
41
|
+
output: Path | None
|
|
42
|
+
rules: list[Rule]
|
|
43
|
+
counts: dict[str, tuple[int, int]]
|
|
44
|
+
warnings: list[str] = field(default_factory=list)
|
|
45
|
+
dry_run: bool = False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _ascii(value: str, label: str) -> None:
|
|
49
|
+
try:
|
|
50
|
+
value.encode("ascii")
|
|
51
|
+
except UnicodeEncodeError as exc:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"{label} must be ASCII so the APK layout stays intact: {value!r}"
|
|
54
|
+
) from exc
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _fit(old: str, new: str, mode: str, pad_char: str) -> str:
|
|
58
|
+
if len(pad_char) != 1 or not pad_char.isascii():
|
|
59
|
+
raise ValueError("pad character must be one ASCII character")
|
|
60
|
+
if len(new) == len(old):
|
|
61
|
+
return new
|
|
62
|
+
if mode == "error":
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"length mismatch for {old!r}: the existing text is {len(old)} characters "
|
|
65
|
+
f"and the replacement is {len(new)}. Use a string of length {len(old)}, "
|
|
66
|
+
"or pass --fit pad or --fit clip."
|
|
67
|
+
)
|
|
68
|
+
if mode == "pad":
|
|
69
|
+
if len(new) > len(old):
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"replacement for {old!r} is {len(new)} characters and the existing text "
|
|
72
|
+
f"is {len(old)}. --fit pad only fills a shorter string."
|
|
73
|
+
)
|
|
74
|
+
return new + pad_char * (len(old) - len(new))
|
|
75
|
+
if mode == "clip":
|
|
76
|
+
clipped = new[: len(old)]
|
|
77
|
+
return clipped + pad_char * (len(old) - len(clipped))
|
|
78
|
+
raise ValueError(f"unknown fit mode: {mode}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def obfuscated_token(text: str, blocked: list[str], seed: str) -> str:
|
|
82
|
+
alphabet = "abcdefghijklmnopqrstuvwxyz"
|
|
83
|
+
blocked_lower = [item.lower() for item in blocked if item]
|
|
84
|
+
counter = 0
|
|
85
|
+
while counter < 1000:
|
|
86
|
+
material = hashlib.sha256(f"{seed}:{counter}:{text}".encode()).digest()
|
|
87
|
+
chars: list[str] = []
|
|
88
|
+
while len(chars) < len(text):
|
|
89
|
+
material = hashlib.sha256(material).digest()
|
|
90
|
+
chars.extend(alphabet[byte % 26] for byte in material)
|
|
91
|
+
token = "".join(chars[: len(text)])
|
|
92
|
+
haystack = token.lower()
|
|
93
|
+
if token != text and all(item not in haystack for item in blocked_lower):
|
|
94
|
+
return token
|
|
95
|
+
counter += 1
|
|
96
|
+
raise RuntimeError(f"could not build an obfuscated replacement for {text!r}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _replace_span(data: bytes, old: bytes, new: bytes) -> tuple[bytes, int]:
|
|
100
|
+
if len(old) != len(new):
|
|
101
|
+
raise ValueError("internal replacement lengths differ")
|
|
102
|
+
count = data.count(old)
|
|
103
|
+
if count:
|
|
104
|
+
data = data.replace(old, new)
|
|
105
|
+
return data, count
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def apply_rules(
|
|
109
|
+
data: bytes, rules: list[Rule]
|
|
110
|
+
) -> tuple[bytes, dict[str, tuple[int, int]]]:
|
|
111
|
+
counts = {rule.old: [0, 0] for rule in rules}
|
|
112
|
+
for rule in rules:
|
|
113
|
+
for form in rule.forms:
|
|
114
|
+
data, utf8_count = _replace_span(
|
|
115
|
+
data, form.encode("utf-8"), rule.new.encode("utf-8")
|
|
116
|
+
)
|
|
117
|
+
data, utf16_count = _replace_span(
|
|
118
|
+
data,
|
|
119
|
+
form.encode("utf-16le"),
|
|
120
|
+
rule.new.encode("utf-16le"),
|
|
121
|
+
)
|
|
122
|
+
counts[rule.old][0] += utf8_count
|
|
123
|
+
counts[rule.old][1] += utf16_count
|
|
124
|
+
return data, {key: (value[0], value[1]) for key, value in counts.items()}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def separator_forms(text: str) -> tuple[str, ...]:
|
|
128
|
+
"""Android class names use '/' in DEX and '.' everywhere else."""
|
|
129
|
+
return tuple(dict.fromkeys((text, text.replace(".", "/"), text.replace("/", "."))))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def present_in(data: bytes, text: str) -> bool:
|
|
133
|
+
return text.encode("utf-8") in data or text.encode("utf-16le") in data
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def rule_present(data: bytes, rule: Rule) -> bool:
|
|
137
|
+
return any(present_in(data, form) for form in rule.forms)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def fix_dex(data: bytes) -> bytes:
|
|
141
|
+
if not data.startswith(b"dex\n"):
|
|
142
|
+
return data
|
|
143
|
+
raw = bytearray(data)
|
|
144
|
+
raw[12:32] = hashlib.sha1(raw[32:]).digest()
|
|
145
|
+
raw[8:12] = struct.pack("<I", zlib.adler32(bytes(raw[12:])) & 0xFFFFFFFF)
|
|
146
|
+
return bytes(raw)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _skipped(name: str) -> bool:
|
|
150
|
+
lower = name.lower()
|
|
151
|
+
return any(lower.endswith(suffix) for suffix in SKIP_SUFFIXES)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def build_rules(
|
|
155
|
+
contains: list[str] | None,
|
|
156
|
+
exact: list[tuple[str, str]] | None,
|
|
157
|
+
*,
|
|
158
|
+
seed: str,
|
|
159
|
+
fit: str,
|
|
160
|
+
pad_char: str,
|
|
161
|
+
) -> list[Rule]:
|
|
162
|
+
contains = list(contains or [])
|
|
163
|
+
exact = list(exact or [])
|
|
164
|
+
if not contains and not exact:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
"provide at least one --contains substring or --exact replacement"
|
|
167
|
+
)
|
|
168
|
+
for text in contains:
|
|
169
|
+
if not text:
|
|
170
|
+
raise ValueError("--contains does not accept an empty string")
|
|
171
|
+
_ascii(text, "--contains")
|
|
172
|
+
seen = set()
|
|
173
|
+
for text in contains:
|
|
174
|
+
if text in seen:
|
|
175
|
+
raise ValueError(f"repeated --contains value: {text!r}")
|
|
176
|
+
seen.add(text)
|
|
177
|
+
|
|
178
|
+
exact_rules: list[Rule] = []
|
|
179
|
+
used_exact: set[str] = set()
|
|
180
|
+
for old, new in exact:
|
|
181
|
+
if not old:
|
|
182
|
+
raise ValueError("--exact does not accept an empty search string")
|
|
183
|
+
_ascii(old, "exact search text")
|
|
184
|
+
_ascii(new, "exact replacement")
|
|
185
|
+
if old in used_exact:
|
|
186
|
+
raise ValueError(f"repeated --exact search string: {old!r}")
|
|
187
|
+
used_exact.add(old)
|
|
188
|
+
fitted = _fit(old, new, fit, pad_char)
|
|
189
|
+
for text in contains:
|
|
190
|
+
if text in fitted:
|
|
191
|
+
raise ValueError(
|
|
192
|
+
f"exact replacement {fitted!r} still contains --contains value {text!r}. "
|
|
193
|
+
"A later substring pass would change it."
|
|
194
|
+
)
|
|
195
|
+
exact_rules.append(Rule("exact", old, fitted, (old,)))
|
|
196
|
+
|
|
197
|
+
blocked = (
|
|
198
|
+
contains
|
|
199
|
+
+ [rule.old for rule in exact_rules]
|
|
200
|
+
+ [rule.new for rule in exact_rules]
|
|
201
|
+
)
|
|
202
|
+
contain_rules = [
|
|
203
|
+
Rule(
|
|
204
|
+
"contains",
|
|
205
|
+
text,
|
|
206
|
+
obfuscated_token(text, blocked, seed),
|
|
207
|
+
separator_forms(text),
|
|
208
|
+
)
|
|
209
|
+
for text in sorted(contains, key=len, reverse=True)
|
|
210
|
+
]
|
|
211
|
+
exact_rules.sort(key=lambda rule: len(rule.old), reverse=True)
|
|
212
|
+
return exact_rules + contain_rules
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def rewrite_apk(
|
|
216
|
+
source: str | Path,
|
|
217
|
+
dest: str | Path,
|
|
218
|
+
contains: list[str] | None = None,
|
|
219
|
+
exact: list[tuple[str, str]] | None = None,
|
|
220
|
+
*,
|
|
221
|
+
seed: str = "softcloak",
|
|
222
|
+
fit: str = "error",
|
|
223
|
+
pad_char: str = " ",
|
|
224
|
+
allow_missing: bool = False,
|
|
225
|
+
dry_run: bool = False,
|
|
226
|
+
keystore: str | Path | None = None,
|
|
227
|
+
storepass: str | None = None,
|
|
228
|
+
keypass: str | None = None,
|
|
229
|
+
alias: str = "repack",
|
|
230
|
+
) -> RewriteResult:
|
|
231
|
+
source_path = Path(source)
|
|
232
|
+
dest_path = Path(dest)
|
|
233
|
+
if not source_path.is_file():
|
|
234
|
+
raise ValueError(f"APK not found: {source_path}")
|
|
235
|
+
rules = build_rules(contains, exact, seed=seed, fit=fit, pad_char=pad_char)
|
|
236
|
+
|
|
237
|
+
with zipfile.ZipFile(source_path) as archive:
|
|
238
|
+
stored = [(info, archive.read(info.filename)) for info in archive.infolist()]
|
|
239
|
+
|
|
240
|
+
warnings: list[str] = []
|
|
241
|
+
missing: list[str] = []
|
|
242
|
+
searchable = []
|
|
243
|
+
for info, data in stored:
|
|
244
|
+
if is_signature_entry(info.filename) or _skipped(info.filename):
|
|
245
|
+
hits = [rule.old for rule in rules if rule_present(data, rule)]
|
|
246
|
+
if hits:
|
|
247
|
+
reason = (
|
|
248
|
+
"signature file"
|
|
249
|
+
if is_signature_entry(info.filename)
|
|
250
|
+
else "skipped binary file"
|
|
251
|
+
)
|
|
252
|
+
warnings.append(
|
|
253
|
+
f"{info.filename} ({reason}) still contains: {', '.join(hits)}. "
|
|
254
|
+
"Those bytes were left unchanged."
|
|
255
|
+
)
|
|
256
|
+
continue
|
|
257
|
+
searchable.append((info, data))
|
|
258
|
+
|
|
259
|
+
for rule in rules:
|
|
260
|
+
if not any(rule_present(data, rule) for _, data in searchable):
|
|
261
|
+
missing.append(rule.old)
|
|
262
|
+
if missing and not allow_missing:
|
|
263
|
+
joined = ", ".join(repr(item) for item in missing)
|
|
264
|
+
raise ValueError(f"no occurrences found for: {joined}")
|
|
265
|
+
|
|
266
|
+
totals = {rule.old: [0, 0] for rule in rules}
|
|
267
|
+
rewritten: list[tuple[zipfile.ZipInfo, bytes]] = []
|
|
268
|
+
for info, data in searchable:
|
|
269
|
+
updated, counts = apply_rules(data, rules)
|
|
270
|
+
for key, (utf8_count, utf16_count) in counts.items():
|
|
271
|
+
totals[key][0] += utf8_count
|
|
272
|
+
totals[key][1] += utf16_count
|
|
273
|
+
if updated != data and info.filename.endswith(".dex"):
|
|
274
|
+
updated = fix_dex(updated)
|
|
275
|
+
rewritten.append((info, updated))
|
|
276
|
+
|
|
277
|
+
result_counts = {key: (value[0], value[1]) for key, value in totals.items()}
|
|
278
|
+
if dry_run:
|
|
279
|
+
return RewriteResult(None, rules, result_counts, warnings, dry_run=True)
|
|
280
|
+
|
|
281
|
+
same_target = source_path.resolve() == dest_path.resolve()
|
|
282
|
+
output = dest_path
|
|
283
|
+
if same_target:
|
|
284
|
+
output = dest_path.with_name(dest_path.name + ".tmp")
|
|
285
|
+
|
|
286
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
287
|
+
unsigned = Path(temp_dir) / "unsigned.apk"
|
|
288
|
+
build_zip(rewritten, unsigned)
|
|
289
|
+
sign_apk(
|
|
290
|
+
unsigned,
|
|
291
|
+
output,
|
|
292
|
+
keystore=Path(keystore) if keystore else None,
|
|
293
|
+
storepass=storepass,
|
|
294
|
+
keypass=keypass,
|
|
295
|
+
alias=alias,
|
|
296
|
+
)
|
|
297
|
+
if same_target:
|
|
298
|
+
os.replace(output, dest_path)
|
|
299
|
+
output = dest_path
|
|
300
|
+
return RewriteResult(output, rules, result_counts, warnings, dry_run=False)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: softcloak
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Replace strings in an APK and re-sign it.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: ruff>=0.16; extra == "dev"
|
|
9
|
+
|
|
10
|
+
# SoftCloak
|
|
11
|
+
|
|
12
|
+
The goal of this repository is to try and make reverse engineering more difficult. Currently only
|
|
13
|
+
focused on APK files but will eventually move on to IPA files, binaries, firmware, and other software.
|
|
14
|
+
|
|
15
|
+
SoftCloak replaces text inside an APK, then signs the rebuilt package. Substring matches are swapped for one generated token of the same length. Exact matches are swapped for text you choose. The APK is signed with a new jar signature.
|
|
16
|
+
|
|
17
|
+
Same-length replacements keep the DEX, manifest, and resource table aligned, so the file can still be installed.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
A JDK is required. `keytool` and `jarsigner` must be on `PATH`.
|
|
22
|
+
|
|
23
|
+
From the project directory:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install -e ".[dev]"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The `dev` extra installs Ruff. The test suite runs `ruff check` and `ruff format --check`.
|
|
30
|
+
|
|
31
|
+
## Use
|
|
32
|
+
|
|
33
|
+
Generate a token for every occurrence of a substring:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
softcloak app.apk -o rebuilt.apk --contains com.example --contains CompanyName
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Replace one exact string with your own text. The new text has to be the same length:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
softcloak app.apk -o rebuilt.apk --exact "Old full sentence" "New full sentence"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Both can be combined. Exact replacements run first, then substring tokens. The command prints each mapping and checks the signature before it finishes.
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
softcloak app.apk -o rebuilt.apk --dry-run --contains CompanyName
|
|
49
|
+
softcloak app.apk -o rebuilt.apk --exact "abcdef" "xy" --fit pad
|
|
50
|
+
softcloak app.apk -o rebuilt.apk --contains CompanyName --keystore my.keystore --storepass secret --alias mykey
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`--fit pad` fills a shorter exact replacement. `--fit clip` cuts a longer one and pads a shorter one. The default is to stop when the lengths differ.
|
|
54
|
+
|
|
55
|
+
`--contains` replaces that text wherever it sits inside a longer string, in every file, in both UTF-8 and UTF-16. A dotted name also rewrites the DEX form with slashes, so `com.example` changes `Lcom/example/...` as well. Matching is case-sensitive. Pass `AppName` and `appname` separately. Patterns shorter than 4 characters are allowed, but they can hit unrelated bytes.
|
|
56
|
+
|
|
57
|
+
Image, audio, font, and native-library entries are left unchanged. Signature files are removed and replaced by the new signature. Other `META-INF` files are kept and edited.
|
|
58
|
+
|
|
59
|
+
## What can break the app
|
|
60
|
+
|
|
61
|
+
SoftCloak changes only the text you pass in. A same-length rename of your own package or class name can still install and run, because the manifest and `classes.dex` are updated together.
|
|
62
|
+
|
|
63
|
+
The app can crash or fail a lookup when a replacement has to match something that was not changed:
|
|
64
|
+
|
|
65
|
+
- Framework names such as `android` or `java` appear inside system class names. Replacing them makes those calls invalid. A short warning exists for patterns under 4 characters, and it does not stop the run. `android` is long enough to pass through with no warning.
|
|
66
|
+
- Resource paths, such as `res/layout/...`, must match the file names in the APK. SoftCloak does not rename those files.
|
|
67
|
+
- Native libraries are left unchanged. If code now looks up a new name and the library still has the old one, that call fails.
|
|
68
|
+
- Intent actions, content-provider authorities, and server URLs fail when only one side of the match is replaced.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/softcloak/__init__.py
|
|
4
|
+
src/softcloak/__main__.py
|
|
5
|
+
src/softcloak/cli.py
|
|
6
|
+
src/softcloak/package.py
|
|
7
|
+
src/softcloak/rewrite.py
|
|
8
|
+
src/softcloak.egg-info/PKG-INFO
|
|
9
|
+
src/softcloak.egg-info/SOURCES.txt
|
|
10
|
+
src/softcloak.egg-info/dependency_links.txt
|
|
11
|
+
src/softcloak.egg-info/entry_points.txt
|
|
12
|
+
src/softcloak.egg-info/requires.txt
|
|
13
|
+
src/softcloak.egg-info/top_level.txt
|
|
14
|
+
tests/test_rewrite.py
|
|
15
|
+
tests/test_ruff.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
softcloak
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import struct
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
import zipfile
|
|
6
|
+
import zlib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from softcloak.rewrite import (
|
|
10
|
+
apply_rules,
|
|
11
|
+
build_rules,
|
|
12
|
+
fix_dex,
|
|
13
|
+
obfuscated_token,
|
|
14
|
+
rewrite_apk,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TokenTests(unittest.TestCase):
|
|
19
|
+
def test_token_matches_length_and_avoids_blocked_text(self):
|
|
20
|
+
blocked = ["uncrackable1", "owasp", "Bernhard"]
|
|
21
|
+
token = obfuscated_token("uncrackable1", blocked, "seed")
|
|
22
|
+
self.assertEqual(len(token), len("uncrackable1"))
|
|
23
|
+
self.assertTrue(token.isascii())
|
|
24
|
+
self.assertEqual(token, obfuscated_token("uncrackable1", blocked, "seed"))
|
|
25
|
+
for item in blocked:
|
|
26
|
+
self.assertNotIn(item.lower(), token.lower())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RuleTests(unittest.TestCase):
|
|
30
|
+
def test_exact_keeps_a_chosen_string_and_contains_rewrites_the_rest(self):
|
|
31
|
+
rules = build_rules(
|
|
32
|
+
["uncrackable1"],
|
|
33
|
+
[("owasp.mstg.uncrackable1", "com.example.localmsgapp")],
|
|
34
|
+
seed="seed",
|
|
35
|
+
fit="error",
|
|
36
|
+
pad_char=" ",
|
|
37
|
+
)
|
|
38
|
+
data = b"owasp.mstg.uncrackable1 plus uncrackable1"
|
|
39
|
+
updated, counts = apply_rules(data, rules)
|
|
40
|
+
self.assertIn(b"com.example.localmsgapp", updated)
|
|
41
|
+
self.assertNotIn(b"uncrackable1", updated)
|
|
42
|
+
self.assertNotIn(b"owasp.mstg.uncrackable1", updated)
|
|
43
|
+
self.assertEqual(counts["owasp.mstg.uncrackable1"], (1, 0))
|
|
44
|
+
self.assertEqual(counts["uncrackable1"], (1, 0))
|
|
45
|
+
|
|
46
|
+
def test_dotted_substring_replaces_every_dex_slash_copy(self):
|
|
47
|
+
rules = build_rules(
|
|
48
|
+
["sg.vantagepoint"], None, seed="seed", fit="error", pad_char=" "
|
|
49
|
+
)
|
|
50
|
+
data = (
|
|
51
|
+
b"Lsg/vantagepoint/a/a; Lsg/vantagepoint/uncrackable1/MainActivity; "
|
|
52
|
+
b"sg.vantagepoint.MainActivity"
|
|
53
|
+
)
|
|
54
|
+
data += "prefix sg.vantagepoint.MainActivity".encode("utf-16le")
|
|
55
|
+
updated, counts = apply_rules(data, rules)
|
|
56
|
+
self.assertNotIn(b"vantagepoint", updated)
|
|
57
|
+
self.assertNotIn("vantagepoint".encode("utf-16le"), updated)
|
|
58
|
+
self.assertEqual(counts["sg.vantagepoint"], (3, 1))
|
|
59
|
+
self.assertIn(b"L", updated)
|
|
60
|
+
self.assertIn(b"/a/a;", updated)
|
|
61
|
+
|
|
62
|
+
def test_utf16_occurrence_is_replaced(self):
|
|
63
|
+
rules = build_rules(["owasp"], None, seed="seed", fit="error", pad_char=" ")
|
|
64
|
+
data = "pkg owasp end".encode("utf-16le")
|
|
65
|
+
updated, counts = apply_rules(data, rules)
|
|
66
|
+
self.assertEqual(counts["owasp"], (0, 1))
|
|
67
|
+
self.assertNotIn("owasp".encode("utf-16le"), updated)
|
|
68
|
+
|
|
69
|
+
def test_length_mismatch_is_rejected(self):
|
|
70
|
+
with self.assertRaises(ValueError):
|
|
71
|
+
build_rules(
|
|
72
|
+
None,
|
|
73
|
+
[("short", "much longer text")],
|
|
74
|
+
seed="seed",
|
|
75
|
+
fit="error",
|
|
76
|
+
pad_char=" ",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def test_pad_fit_reaches_the_original_length(self):
|
|
80
|
+
rules = build_rules(
|
|
81
|
+
None, [("abcdef", "xy")], seed="seed", fit="pad", pad_char="."
|
|
82
|
+
)
|
|
83
|
+
self.assertEqual(rules[0].new, "xy....")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class DexTests(unittest.TestCase):
|
|
87
|
+
def test_checksum_matches_the_patched_bytes(self):
|
|
88
|
+
raw = bytearray(b"dex\n035\x00" + b"\x00" * 120)
|
|
89
|
+
raw.extend(b"uncrackable1")
|
|
90
|
+
rules = build_rules(
|
|
91
|
+
["uncrackable1"], None, seed="seed", fit="error", pad_char=" "
|
|
92
|
+
)
|
|
93
|
+
updated, _counts = apply_rules(bytes(raw), rules)
|
|
94
|
+
patched = fix_dex(updated)
|
|
95
|
+
self.assertNotIn(b"uncrackable1", patched)
|
|
96
|
+
checksum = struct.unpack_from("<I", patched, 8)[0]
|
|
97
|
+
self.assertEqual(checksum, zlib.adler32(patched[12:]) & 0xFFFFFFFF)
|
|
98
|
+
self.assertEqual(hashlib.sha1(patched[32:]).digest(), patched[12:32])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ApkTests(unittest.TestCase):
|
|
102
|
+
def test_rewrite_signs_and_removes_requested_text(self):
|
|
103
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
104
|
+
root = Path(temp_dir)
|
|
105
|
+
source = root / "sample.apk"
|
|
106
|
+
dest = root / "sample-out.apk"
|
|
107
|
+
dex = bytearray(b"dex\n035\x00" + b"\x00" * 120)
|
|
108
|
+
dex.extend(b"Lsg/vantagepoint/uncrackable1/MainActivity;")
|
|
109
|
+
manifest = "name=owasp.mstg.uncrackable1".encode("utf-16le")
|
|
110
|
+
resources = b"Thanks to Bernhard Mueller for the demo."
|
|
111
|
+
with zipfile.ZipFile(source, "w") as archive:
|
|
112
|
+
archive.writestr("classes.dex", bytes(dex))
|
|
113
|
+
archive.writestr("AndroidManifest.xml", manifest)
|
|
114
|
+
archive.writestr("resources.arsc", resources)
|
|
115
|
+
archive.writestr("res/icon.png", b"\x89PNG uncrackable1")
|
|
116
|
+
archive.writestr("META-INF/services/demo", b"keep-me uncrackable1")
|
|
117
|
+
|
|
118
|
+
result = rewrite_apk(
|
|
119
|
+
source,
|
|
120
|
+
dest,
|
|
121
|
+
contains=["uncrackable1", "vantagepoint", "owasp"],
|
|
122
|
+
exact=[("Bernhard Mueller", "Replacement Text")],
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
self.assertTrue(dest.is_file())
|
|
126
|
+
self.assertIn("res/icon.png", " ".join(result.warnings))
|
|
127
|
+
with zipfile.ZipFile(dest) as archive:
|
|
128
|
+
names = set(archive.namelist())
|
|
129
|
+
blob = b"".join(
|
|
130
|
+
archive.read(name)
|
|
131
|
+
for name in archive.namelist()
|
|
132
|
+
if not name.startswith("META-INF/")
|
|
133
|
+
)
|
|
134
|
+
self.assertIn("META-INF/services/demo", names)
|
|
135
|
+
self.assertNotIn(b"uncrackable1", blob)
|
|
136
|
+
self.assertNotIn(b"vantagepoint", blob)
|
|
137
|
+
self.assertNotIn("owasp".encode("utf-16le"), blob)
|
|
138
|
+
self.assertNotIn(b"Bernhard Mueller", blob)
|
|
139
|
+
self.assertIn(b"Replacement Text", blob)
|
|
140
|
+
dex_out = archive.read("classes.dex")
|
|
141
|
+
checksum = struct.unpack_from("<I", dex_out, 8)[0]
|
|
142
|
+
self.assertEqual(checksum, zlib.adler32(dex_out[12:]) & 0xFFFFFFFF)
|
|
143
|
+
self.assertIn(b"keep-me", archive.read("META-INF/services/demo"))
|
|
144
|
+
self.assertNotIn(
|
|
145
|
+
b"uncrackable1", archive.read("META-INF/services/demo")
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
unittest.main()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
import unittest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RuffTests(unittest.TestCase):
|
|
10
|
+
def test_ruff_lint(self):
|
|
11
|
+
self._run_ruff("check")
|
|
12
|
+
|
|
13
|
+
def test_ruff_format(self):
|
|
14
|
+
self._run_ruff("format", "--check")
|
|
15
|
+
|
|
16
|
+
def _run_ruff(self, *args: str) -> None:
|
|
17
|
+
result = subprocess.run(
|
|
18
|
+
[sys.executable, "-m", "ruff", *args, "src", "tests"],
|
|
19
|
+
cwd=ROOT,
|
|
20
|
+
capture_output=True,
|
|
21
|
+
text=True,
|
|
22
|
+
check=False,
|
|
23
|
+
)
|
|
24
|
+
if result.returncode == 0:
|
|
25
|
+
return
|
|
26
|
+
detail = (result.stdout + result.stderr).strip()
|
|
27
|
+
if "No module named ruff" in detail:
|
|
28
|
+
self.fail(
|
|
29
|
+
'ruff is not installed. Install the dev extra: pip install -e ".[dev]"'
|
|
30
|
+
)
|
|
31
|
+
self.fail(detail)
|