diff --git a/telethon/crypto/__init__.py b/telethon/crypto/__init__.py index 9b4fdb73..d151a96c 100644 --- a/telethon/crypto/__init__.py +++ b/telethon/crypto/__init__.py @@ -1,4 +1,5 @@ from .aes import AES +from .aes_ctr import AESModeCTR from .auth_key import AuthKey from .factorization import Factorization from .cdn_decrypter import CdnDecrypter diff --git a/telethon/crypto/aes_ctr.py b/telethon/crypto/aes_ctr.py new file mode 100644 index 00000000..bf8edd74 --- /dev/null +++ b/telethon/crypto/aes_ctr.py @@ -0,0 +1,29 @@ +import pyaes + + +class AESModeCTR: + """Wrapper around pyaes.AESModeOfOperationCTR mode with custom IV""" + # TODO Maybe make a pull request to pyaes to support iv on CTR + + def __init__(self, key, iv): + # TODO Use libssl if available + assert isinstance(key, bytes) + self._aes = pyaes.AESModeOfOperationCTR(key) + + assert isinstance(iv, bytes) + assert len(iv) == 16 + self.iv = iv + self._aes._counter._counter = list(self.iv) + + def reset(self): + pass + + def encrypt(self, data): + result = self._aes.encrypt(data) + self.reset() + return result + + def decrypt(self, data): + result = self._aes.decrypt(data) + self.reset() + return result diff --git a/telethon/extensions/__init__.py b/telethon/extensions/__init__.py index 420e4bda..46de4467 100644 --- a/telethon/extensions/__init__.py +++ b/telethon/extensions/__init__.py @@ -6,4 +6,5 @@ strings, bytes, etc.) """ from .binary_writer import BinaryWriter from .binary_reader import BinaryReader -from .tcp_client import TcpClient \ No newline at end of file +from .tcp_client import TcpClient +from .tcp_client_obfuscated import TcpClientObfuscated diff --git a/telethon/extensions/tcp_client_obfuscated.py b/telethon/extensions/tcp_client_obfuscated.py new file mode 100644 index 00000000..302c388e --- /dev/null +++ b/telethon/extensions/tcp_client_obfuscated.py @@ -0,0 +1,171 @@ +# Python rough implementation of a C# TCP client +import socket +import time +import os +from datetime import datetime, timedelta +from io import BytesIO, BufferedWriter +from threading import Event, Lock +import errno + +from ..crypto import AESModeCTR +from ..errors import ReadCancelledError + + +# Obfuscated messages secrets cannot start with any of these +OBFUSCATED_ANTI_KEYWORDS = (b'PVrG', b'GET ', b'POST', b'\xee' * 4) + + +class TcpClientObfuscated: + # TODO Avoid duplicating so much code - transport for TCPO + + def __init__(self, proxy=None): + self.connected = False + self._proxy = proxy + self._recreate_socket() + + # Support for multi-threading advantages and safety + self.cancelled = Event() # Has the read operation been cancelled? + self.delay = 0.1 # Read delay when there was no data available + self._lock = Lock() + + self.aes_encrypt = None + self.aes_decrypt = None + + def _recreate_socket(self): + if self._proxy is None: + self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + else: + import socks + self._socket = socks.socksocket(socket.AF_INET, socket.SOCK_STREAM) + if type(self._proxy) is dict: + self._socket.set_proxy(**self._proxy) + else: # tuple, list, etc. + self._socket.set_proxy(*self._proxy) + + def connect(self, ip, port, timeout): + """Connects to the specified IP and port number. + 'timeout' must be given in seconds + """ + if not self.connected: + self._socket.settimeout(timeout) + self._socket.connect((ip, port)) + self._socket.setblocking(False) + self.connected = True + + # TCP Obfuscated bits + while True: + random = os.urandom(64) + if (random[0] != b'\xef' and + random[:4] not in OBFUSCATED_ANTI_KEYWORDS and + random[4:4] != b'\0\0\0\0'): + # Invalid random generated + break + + random = list(random) + random[56] = random[57] = random[58] = random[59] = 0xef + random_reversed = random[55:7:-1] # Reversed (8, len=48) + + # encryption has "continuous buffer" enabled + encrypt_key = bytes(random[8:40]) + encrypt_iv = bytes(random[40:56]) + decrypt_key = bytes(random_reversed[:32]) + decrypt_iv = bytes(random_reversed[32:48]) + + self.aes_encrypt = AESModeCTR(encrypt_key, encrypt_iv) + self.aes_decrypt = AESModeCTR(decrypt_key, decrypt_iv) + + random[56:64] = self.aes_encrypt.encrypt(bytes(random))[56:64] + self._socket.sendall(bytes(random)) + + def close(self): + """Closes the connection""" + if self.connected: + try: + self._socket.shutdown(socket.SHUT_RDWR) + self._socket.close() + except OSError as e: + if e.errno != errno.ENOTCONN: + raise + + self.connected = False + self._recreate_socket() + + def write(self, data): + """Writes (sends) the specified bytes to the connected peer""" + data = self.aes_encrypt.encrypt(data) + + # Ensure that only one thread can send data at once + with self._lock: + try: + view = memoryview(data) + total_sent, total = 0, len(data) + while total_sent < total: + try: + sent = self._socket.send(view[total_sent:]) + if sent == 0: + raise ConnectionResetError( + 'The server has closed the connection.') + total_sent += sent + + except BlockingIOError: + time.sleep(self.delay) + except BrokenPipeError: + self.close() + raise + + def read(self, size, timeout=timedelta(seconds=5)): + """Reads (receives) a whole block of 'size bytes + from the connected peer. + + A timeout can be specified, which will cancel the operation if + no data has been read in the specified time. If data was read + and it's waiting for more, the timeout will NOT cancel the + operation. Set to None for no timeout + """ + + # Ensure that only one thread can receive data at once + with self._lock: + # Ensure it is not cancelled at first, so we can enter the loop + self.cancelled.clear() + + # Set the starting time so we can + # calculate whether the timeout should fire + start_time = datetime.now() if timeout is not None else None + + with BufferedWriter(BytesIO(), buffer_size=size) as buffer: + bytes_left = size + while bytes_left != 0: + # Only do cancel if no data was read yet + # Otherwise, carry on reading and finish + if self.cancelled.is_set() and bytes_left == size: + raise ReadCancelledError() + + try: + partial = self._socket.recv(bytes_left) + if len(partial) == 0: + self.close() + raise ConnectionResetError( + 'The server has closed the connection.') + + buffer.write(partial) + bytes_left -= len(partial) + + except BlockingIOError as error: + # No data available yet, sleep a bit + time.sleep(self.delay) + + # Check if the timeout finished + if timeout is not None: + time_passed = datetime.now() - start_time + if time_passed > timeout: + raise TimeoutError( + 'The read operation exceeded the timeout.') from error + + # If everything went fine, return the read bytes + buffer.flush() + return self.aes_decrypt.encrypt(buffer.raw.getvalue()) + + def cancel_read(self): + """Cancels the read operation IF it hasn't yet + started, raising a ReadCancelledError""" + self.cancelled.set() diff --git a/telethon/network/tcp_transport.py b/telethon/network/tcp_transport.py index 76b6b6b9..65047ac1 100644 --- a/telethon/network/tcp_transport.py +++ b/telethon/network/tcp_transport.py @@ -2,7 +2,7 @@ from zlib import crc32 from datetime import timedelta from ..errors import InvalidChecksumError -from ..extensions import TcpClient +from ..extensions import TcpClient, TcpClientObfuscated from ..extensions import BinaryWriter @@ -11,7 +11,7 @@ class TcpTransport: proxy=None, timeout=timedelta(seconds=5)): self.ip = ip_address self.port = port - self.tcp_client = TcpClient(proxy) + self.tcp_client = TcpClientObfuscated(proxy) self.timeout = timeout self.send_counter = 0 @@ -33,14 +33,27 @@ class TcpTransport: raise ConnectionError('Client not connected to server.') with BinaryWriter() as writer: - writer.write_int(len(packet) + 12) # 12 = size_of (integer) * 3 - writer.write_int(self.send_counter) - writer.write(packet) + if isinstance(self.tcp_client, TcpClient): + # 12 = size_of (integer) * 3 + writer.write_int(len(packet) + 12) + writer.write_int(self.send_counter) + writer.write(packet) - crc = crc32(writer.get_bytes()) - writer.write_int(crc, signed=False) + crc = crc32(writer.get_bytes()) + writer.write_int(crc, signed=False) + + self.send_counter += 1 + elif isinstance(self.tcp_client, TcpClientObfuscated): + length = len(packet) >> 2 + if length < 127: + writer.write_byte(length) + else: + writer.write_byte(127) + writer.write(int.to_bytes(length, 3, 'little')) + writer.write(packet) + else: + raise ValueError('Unknown client') - self.send_counter += 1 self.tcp_client.write(writer.get_bytes()) def receive(self, **kwargs): @@ -50,29 +63,40 @@ class TcpTransport: If a named 'timeout' parameter is present, it will override 'self.timeout', and this can be a 'timedelta' or 'None'. """ - timeout = kwargs.get('timeout', self.timeout) + if isinstance(self.tcp_client, TcpClient): + timeout = kwargs.get('timeout', self.timeout) - # First read everything we need - packet_length_bytes = self.tcp_client.read(4, timeout) - packet_length = int.from_bytes(packet_length_bytes, byteorder='little') + # First read everything we need + packet_length_bytes = self.tcp_client.read(4, timeout) + packet_length = int.from_bytes(packet_length_bytes, byteorder='little') - seq_bytes = self.tcp_client.read(4, timeout) - seq = int.from_bytes(seq_bytes, byteorder='little') + seq_bytes = self.tcp_client.read(4, timeout) + seq = int.from_bytes(seq_bytes, byteorder='little') - body = self.tcp_client.read(packet_length - 12, timeout) + body = self.tcp_client.read(packet_length - 12, timeout) - checksum = int.from_bytes( - self.tcp_client.read(4, timeout), byteorder='little', signed=False) + checksum = int.from_bytes( + self.tcp_client.read(4, timeout), byteorder='little', signed=False) - # Then perform the checks - rv = packet_length_bytes + seq_bytes + body - valid_checksum = crc32(rv) + # Then perform the checks + rv = packet_length_bytes + seq_bytes + body + valid_checksum = crc32(rv) - if checksum != valid_checksum: - raise InvalidChecksumError(checksum, valid_checksum) + if checksum != valid_checksum: + raise InvalidChecksumError(checksum, valid_checksum) - # If we passed the tests, we can then return a valid TCP message - return seq, body + # If we passed the tests, we can then return a valid TCP message + return seq, body + elif isinstance(self.tcp_client, TcpClientObfuscated): + packet_length = int.from_bytes(self.tcp_client.read(1), 'little') + if packet_length < 127: + return 0, self.tcp_client.read(packet_length << 2) + else: + plb = self.tcp_client.read(3) + pl = int.from_bytes(plb + b'\0', 'little') << 2 + return 0, self.tcp_client.read(pl) + else: + raise ValueError('Unknown client') def close(self): self.tcp_client.close()