From b49356ea816b05bec482dc78ac9d20a6d318c36e Mon Sep 17 00:00:00 2001 From: securisec Date: Tue, 16 Apr 2024 20:26:14 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=97=93=20Apr=2016,=202024=208:25:44?= =?UTF-8?q?=E2=80=AFPM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🧪 tests added/updated ✨ to/from base45 ✨ to/from base92 📔 docs added/updated --- .github/workflows/tests_multi_os.yml | 8 +- chepy/chepy_plugins | 2 +- chepy/modules/dataformat.py | 46 ++++++++- chepy/modules/dataformat.pyi | 4 + chepy/modules/internal/helpers.py | 142 ++++++++++++++++++++++++++- docs/examples.md | 22 +++++ tests/test_dataformat.py | 13 +++ 7 files changed, 230 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests_multi_os.yml b/.github/workflows/tests_multi_os.yml index 7464c94..e6f192e 100644 --- a/.github/workflows/tests_multi_os.yml +++ b/.github/workflows/tests_multi_os.yml @@ -15,9 +15,9 @@ jobs: - "3.12" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' @@ -106,9 +106,9 @@ jobs: needs: test if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: setup - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.7" - name: build diff --git a/chepy/chepy_plugins b/chepy/chepy_plugins index 9301523..e43ebb8 160000 --- a/chepy/chepy_plugins +++ b/chepy/chepy_plugins @@ -1 +1 @@ -Subproject commit 93015234ec5e9d76cfc3edb57ba1b4a22b3778e3 +Subproject commit e43ebb8fe23a6a2e9f5701bd00e8c4d6693fa327 diff --git a/chepy/modules/dataformat.py b/chepy/modules/dataformat.py index 2f2f6ed..11561ae 100644 --- a/chepy/modules/dataformat.py +++ b/chepy/modules/dataformat.py @@ -17,6 +17,8 @@ Rotate, Uint1Array, UUEncoderDecoder, + Base92, + Base45, ) yaml = lazy_import.lazy_module("yaml") @@ -340,6 +342,46 @@ def from_base32(self, remove_whitespace: bool = True) -> DataFormatT: self.state = base64.b32decode(self.state) return self + @ChepyDecorators.call_stack + def to_base92(self) -> DataFormatT: + """Encode to Base92 + + Returns: + Chepy: The Chepy object. + """ + self.state = Base92.b92encode(self._convert_to_bytes()) + return self + + @ChepyDecorators.call_stack + def from_base92(self) -> DataFormatT: + """Decode from Base92 + + Returns: + Chepy: The Chepy object. + """ + self.state = Base92.b92decode(self._convert_to_str()) + return self + + @ChepyDecorators.call_stack + def to_base45(self) -> DataFormatT: + """Encode to Base45 + + Returns: + Chepy: The Chepy object. + """ + self.state = Base45().b45encode(self._convert_to_bytes()) + return self + + @ChepyDecorators.call_stack + def from_base45(self) -> DataFormatT: + """Decode from Base45 + + Returns: + Chepy: The Chepy object. + """ + self.state = Base45().b45decode(self._convert_to_bytes()) + return self + @ChepyDecorators.call_stack def to_base91(self) -> DataFormatT: # pragma: no cover """Base91 encode @@ -490,7 +532,9 @@ def to_base64(self, custom: str = None, url_safe: bool = False) -> DataFormatT: return self @ChepyDecorators.call_stack - def from_base64(self, custom: str = None, url_safe: bool = False, remove_whitespace: bool = True) -> DataFormatT: + def from_base64( + self, custom: str = None, url_safe: bool = False, remove_whitespace: bool = True + ) -> DataFormatT: """Decode as Base64 Base64 is a notation for encoding arbitrary byte data using a diff --git a/chepy/modules/dataformat.pyi b/chepy/modules/dataformat.pyi index 144f3e1..e741916 100644 --- a/chepy/modules/dataformat.pyi +++ b/chepy/modules/dataformat.pyi @@ -76,6 +76,10 @@ class DataFormat(ChepyCore): def to_leetcode(self: DataFormatT, replace_space: str=...) -> DataFormatT: ... def substitute(self: DataFormatT, x: str=..., y: str=...) -> DataFormatT: ... def remove_nonprintable(self: DataFormatT, replace_with: Union[str, bytes] = ...): ... + def to_base92(self: DataFormatT) -> DataFormatT: ... + def from_base92(self: DataFormatT) -> DataFormatT: ... + def to_base45(self: DataFormatT) -> DataFormatT: ... + def from_base45(self: DataFormatT) -> DataFormatT: ... def to_base91(self: DataFormatT) -> DataFormatT: ... def from_base91(self: DataFormatT) -> DataFormatT: ... def swap_endianness(self: DataFormatT, word_length: int=...) -> DataFormatT: ... diff --git a/chepy/modules/internal/helpers.py b/chepy/modules/internal/helpers.py index f3ed31c..76f0b04 100644 --- a/chepy/modules/internal/helpers.py +++ b/chepy/modules/internal/helpers.py @@ -2,6 +2,146 @@ import binascii +class Base45: + # reference: https://github.com/kirei/python-base45/blob/main/base45/__init__.py + def __init__(self) -> None: + self.BASE45_CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:" + self.BASE45_DICT = {v: i for i, v in enumerate(self.BASE45_CHARSET)} + + def b45encode(self, buf: bytes) -> bytes: + """Convert bytes to base45-encoded string""" + res = "" + buflen = len(buf) + for i in range(0, buflen & ~1, 2): + x = (buf[i] << 8) + buf[i + 1] + e, x = divmod(x, 45 * 45) + d, c = divmod(x, 45) + res += ( + self.BASE45_CHARSET[c] + self.BASE45_CHARSET[d] + self.BASE45_CHARSET[e] + ) + if buflen & 1: + d, c = divmod(buf[-1], 45) + res += self.BASE45_CHARSET[c] + self.BASE45_CHARSET[d] + return res.encode() + + def b45decode(self, s: Union[bytes, str]) -> bytes: + """Decode base45-encoded string to bytes""" + try: + if isinstance(s, str): # pragma: no cover + buf = [self.BASE45_DICT[c] for c in s.rstrip("\n")] + elif isinstance(s, bytes): + buf = [self.BASE45_DICT[c] for c in s.decode()] + else: # pragma: no cover + raise TypeError("Type must be 'str' or 'bytes'") + + buflen = len(buf) + if buflen % 3 == 1: # pragma: no cover + raise ValueError("Invalid base45 string") + + res = [] + for i in range(0, buflen, 3): + if buflen - i >= 3: + x = buf[i] + buf[i + 1] * 45 + buf[i + 2] * 45 * 45 + if x > 0xFFFF: # pragma: no cover + raise ValueError + res.extend(divmod(x, 256)) + else: + x = buf[i] + buf[i + 1] * 45 + if x > 0xFF: # pragma: no cover + raise ValueError + res.append(x) + return bytes(res) + except (ValueError, KeyError, AttributeError): # pragma: no cover + raise ValueError("Invalid base45 string") + + +class Base92(object): + """ + Reference: https://github.com/Gu-f/py3base92/tree/master + """ + + CHARACTER_SET = r"!#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_abcdefghijklmnopqrstuvwxyz{|}" + + @classmethod + def base92_chr(cls, val): + if val < 0 or val >= 91: # pragma: no cover + raise ValueError("val must be in [0, 91)") + if val == 0: + return "!" # pragma: no cover + elif val <= 61: + return chr(ord("#") + val - 1) + else: + return chr(ord("a") + val - 62) + + @classmethod + def base92_ord(cls, val): + num = ord(val) + if val == "!": + return 0 # pragma: no cover + elif ord("#") <= num and num <= ord("_"): + return num - ord("#") + 1 + elif ord("a") <= num and num <= ord("}"): + return num - ord("a") + 62 + else: # pragma: no cover + raise ValueError("val is not a base92 character") + + @classmethod + def b92encode(cls, byt: bytes) -> str: + if not isinstance(byt, bytes): # pragma: no cover + raise TypeError(f"a bytes-like object is required, not '{type(byt)}'") + if not byt: + return "~" + if not isinstance(byt, str): + byt = "".join([chr(b) for b in byt]) + bitstr = "" + while len(bitstr) < 13 and byt: + bitstr += "{:08b}".format(ord(byt[0])) + byt = byt[1:] + resstr = "" + while len(bitstr) > 13 or byt: + i = int(bitstr[:13], 2) + resstr += cls.base92_chr(i // 91) + resstr += cls.base92_chr(i % 91) + bitstr = bitstr[13:] + while len(bitstr) < 13 and byt: + bitstr += "{:08b}".format(ord(byt[0])) + byt = byt[1:] + + if bitstr: + if len(bitstr) < 7: + bitstr += "0" * (6 - len(bitstr)) + resstr += cls.base92_chr(int(bitstr, 2)) + else: # pragma: no cover + bitstr += "0" * (13 - len(bitstr)) + i = int(bitstr, 2) + resstr += cls.base92_chr(i // 91) + resstr += cls.base92_chr(i % 91) + return resstr + + @classmethod + def b92decode(cls, bstr: str) -> bytes: + if not isinstance(bstr, str): # pragma: no cover + raise TypeError(f"a str object is required, not '{type(bstr)}'") + bitstr = "" + resstr = "" + if bstr == "~": + return "".encode(encoding="latin-1") + + for i in range(len(bstr) // 2): + x = cls.base92_ord(bstr[2 * i]) * 91 + cls.base92_ord(bstr[2 * i + 1]) + bitstr += "{:013b}".format(x) + while 8 <= len(bitstr): + resstr += chr(int(bitstr[0:8], 2)) + bitstr = bitstr[8:] + if len(bstr) % 2 == 1: + x = cls.base92_ord(bstr[-1]) + bitstr += "{:06b}".format(x) + while 8 <= len(bitstr): + resstr += chr(int(bitstr[0:8], 2)) + bitstr = bitstr[8:] + return resstr.encode(encoding="latin-1") + + class LZ77Compressor: """ Class containing compress and decompress methods using LZ77 compression algorithm. @@ -168,7 +308,7 @@ def detect_delimiter( """ if default_delimiter: return default_delimiter - + is_bytes = False if isinstance(data, bytes): # pragma: no cover delimiters = [d.encode() for d in delimiters] diff --git a/docs/examples.md b/docs/examples.md index b528396..b4e61b1 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -78,6 +78,28 @@ print(c) # CTF +#### Incognito 5.0 - Marathon +```py +from chepy import Chepy + +data = '3430203633203639203538203664203763203334203731203366203362203562203731203262203332203366203231203366203564203662203362203365203538203635203330203435203634203739203239203236203530203634203566203363203333203331203532203564203530203265203634203239203533203632203539203535203763203634203632203462203362203533203731203261203733203333203563203430203436203435203461203238203262203466203733203434203539203533203634203663203536203331203334203233203539203565203233203365203537203535203634203438203261203362203635203336203363203634203464203664203666203436203332203236203431203238203436203338203737203533203363203263203461203335203737203239203535203663203463203665203233203333203732203361203236203363203364203436203764203331203731203233203331203530203562203538203439203532203438203366203539203330203632203331203738203261203530203361203231203631203734203536203537203535203637203362203264203531203365203533203633203334203430203733203762203565203639203263203365203439203737203430203236203736203331203639203733203330203665203334203436203536203363203239203337203631203633203339203566203439203265203265203764203432' +c = ( + Chepy(data) + .from_hex() + .from_hex() + .from_base92() + .from_base64() + .from_base62() + .from_base58() + .from_base45() + .from_base32() + .rot_13() +) + +print(c) +ictf{D3c0d3_4ll_7h3_W@y} +``` + #### HTB Business 2022 - Perseverence ```py import base64 diff --git a/tests/test_dataformat.py b/tests/test_dataformat.py index fe2e600..4804b7b 100644 --- a/tests/test_dataformat.py +++ b/tests/test_dataformat.py @@ -770,3 +770,16 @@ def test_rison(): data = {"b": True, "c": {"d": [1, 2]}} assert Chepy(data).to_rison().o == b"(b:!t,c:(d:!(1,2)))" assert Chepy("(b:!t,c:(d:!(1,2)))").from_rison().o == data + + +def test_base92(): + data = "hello" + assert Chepy(data).to_base92().o == b"Fc_$aOB" + assert Chepy("").to_base92().o == b"~" + assert Chepy(b"Fc_$aOB").from_base92().o == b"hello" + assert Chepy("~").from_base92().o == b"" + + +def test_base45(): + assert Chepy("+8D VDL2").from_base45().o == b"hello" + assert Chepy("hello").to_base45().o == b"+8D VDL2"