Skip to content

Commit a736c46

Browse files
committed
feat: project obfuscate
This serves more as a test of the capabilities of sa.editor and allowed me to find a few bugs, rather than something you should use much. Uploading obfuscated code to scratch is against the community guidelines, however some people may find it useful - e.g. in comination with the turbowarp packager to deliver 'proprietary-like' obfuscated scratch projects for use outside of the scratch website.
1 parent 7519812 commit a736c46

File tree

4 files changed

+131
-4
lines changed

4 files changed

+131
-4
lines changed

scratchattach/editor/asset.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class AssetFile:
1515
- stores the filename, data, and md5 hash
1616
"""
1717
filename: str
18-
_data: bytes = field(repr=False, default_factory=bytes)
18+
_data: Optional[bytes] = field(repr=False, default=None)
1919
_md5: str = field(repr=False, default_factory=str)
2020

2121
@property
@@ -26,6 +26,7 @@ def data(self):
2626
if self._data is None:
2727
# Download and cache
2828
rq = requests.get(f"https://assets.scratch.mit.edu/internalapi/asset/{self.filename}/get/")
29+
# print(f"Downloaded {url}")
2930
if rq.status_code != 200:
3031
raise ValueError(f"Can't download asset {self.filename}\nIs not uploaded to scratch! Response: {rq.text}")
3132

@@ -206,10 +207,12 @@ def to_json(self) -> dict:
206207
"""
207208
_json = super().to_json()
208209
_json.update({
209-
"bitmapResolution": self.bitmap_resolution,
210210
"rotationCenterX": self.rotation_center_x,
211211
"rotationCenterY": self.rotation_center_y
212212
})
213+
if self.bitmap_resolution is not None:
214+
_json["bitmapResolution"] = self.bitmap_resolution
215+
213216
return _json
214217

215218

scratchattach/editor/mutation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ def argument_settings(self) -> ArgSettings:
261261
bool(commons.safe_get(self.argument_defaults, 0)))
262262

263263
@property
264-
def parsed_proc_code(self) -> list[str, ArgumentType] | None:
264+
def parsed_proc_code(self) -> list[str | ArgumentType] | None:
265265
"""
266266
Parse the proc code into arguments & strings
267267
"""

scratchattach/editor/project.py

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22

33
import json
44
import os
5+
import string
56
import warnings
67
from io import BytesIO, TextIOWrapper
78
from typing import Optional, Iterable, Generator, BinaryIO
89
from typing_extensions import deprecated
910
from zipfile import ZipFile
1011

11-
from . import base, meta, extension, monitor, sprite, asset, vlb, twconfig, comment, commons
12+
from . import base, meta, extension, monitor, sprite, asset, vlb, twconfig, comment, commons, mutation
1213
from scratchattach.site import session
1314
from scratchattach.utils import exceptions
1415

@@ -284,3 +285,96 @@ def add_monitor(self, _monitor: monitor.Monitor) -> monitor.Monitor:
284285
_monitor.project = self
285286
_monitor.reporter_id = self.new_id
286287
self.monitors.append(_monitor)
288+
return _monitor
289+
290+
def obfuscate(self, *, goto_origin: bool=True) -> None:
291+
"""
292+
Randomly set all the variable names etc. Do not upload this project to the scratch website, as it is
293+
against the community guidelines.
294+
"""
295+
# this code is an embarrassing mess. If certain features are added to sa.editor, then it could become a lot cleaner
296+
chars = string.ascii_letters + string.digits + string.punctuation
297+
298+
def b10_to_cbase(b10: int | float):
299+
ret = ''
300+
new_base = len(chars)
301+
while b10 >= 1:
302+
ret = chars[int(b10 % new_base)] + ret
303+
b10 /= new_base
304+
305+
return ret
306+
307+
used = 0
308+
309+
def rand():
310+
nonlocal used
311+
used += 1
312+
313+
return b10_to_cbase(used)
314+
315+
for _sprite in self.sprites:
316+
procedure_mappings: dict[str, str] = {}
317+
argument_mappings: dict[str, str] = {}
318+
done_args: list[mutation.Argument] = []
319+
320+
for _variable in _sprite.variables:
321+
_variable.name = rand()
322+
for _list in _sprite.lists:
323+
_list.name = rand()
324+
# don't rename broadcasts as these can be dynamically called
325+
326+
def arg_get(name: str) -> str:
327+
if name not in argument_mappings:
328+
argument_mappings[name] = rand()
329+
return argument_mappings[name]
330+
331+
for _block in _sprite.blocks.values():
332+
if goto_origin:
333+
_block.x, _block.y = 0, 0
334+
335+
if _block.opcode in ("procedures_call", "procedures_prototype", "procedures_definition"):
336+
if _block.mutation is None:
337+
continue
338+
339+
proccode = _block.mutation.proc_code
340+
if proccode is None:
341+
continue
342+
343+
if proccode not in procedure_mappings:
344+
parsed_ppc = _block.mutation.parsed_proc_code
345+
346+
if parsed_ppc is None:
347+
continue
348+
349+
new: list[str | mutation.ArgumentType] = []
350+
for item in parsed_ppc:
351+
if isinstance(item, str):
352+
item = rand()
353+
354+
new.append(item)
355+
356+
new_proccode = mutation.construct_proccode(*new)
357+
procedure_mappings[proccode] = new_proccode
358+
359+
_block.mutation.proc_code = procedure_mappings[proccode]
360+
361+
assert _block.mutation.arguments is not None
362+
for arg in _block.mutation.arguments:
363+
if arg in done_args:
364+
continue
365+
done_args.append(arg)
366+
367+
arg.name = arg_get(arg.name)
368+
369+
# print(_block, _block.mutation)
370+
elif _block.opcode in ("argument_reporter_string_number", "argument_reporter_boolean"):
371+
arg_name = _block.fields["VALUE"].value
372+
assert isinstance(arg_name, str)
373+
_block.fields["VALUE"].value = arg_get(arg_name)
374+
375+
376+
# print(argument_mappings)
377+
378+
if goto_origin:
379+
for _comment in _sprite.comments:
380+
_comment.x, _comment.y = 0, 0

tests/test_editor_obfuscation.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import pprint
2+
import sys
3+
from pathlib import Path
4+
5+
6+
def test_project():
7+
sys.path.insert(0, ".")
8+
import scratchattach as sa
9+
from util import session
10+
sess = session()
11+
12+
path = Path(__file__).parent.parent / "intro for kelmare (yoda tour) (p2).sb3"
13+
14+
if path.exists():
15+
print(f"loading cached {path}")
16+
body = sa.editor.Project.from_sb3(path.open("rb"))
17+
else:
18+
print(f"Could not find {path}")
19+
project = sess.connect_project(1074489898)
20+
body = project.body()
21+
22+
body.obfuscate()
23+
24+
# body.save_json("obfuscated")
25+
body.export("obfuscated.sb3")
26+
27+
28+
29+
if __name__ == '__main__':
30+
test_project()

0 commit comments

Comments
 (0)