From 12e9f4857a38d4d81674499963182be149fe90b8 Mon Sep 17 00:00:00 2001 From: Takahiro Yoshimura Date: Sat, 23 Nov 2024 13:38:27 +0900 Subject: [PATCH] Experimentally analyzing iOS app --- trueseeing/core/context.py | 5 + trueseeing/core/ios/__init__.py | 0 trueseeing/core/ios/analyze.py | 221 ++++++++ trueseeing/core/ios/context.py | 129 +++++ trueseeing/core/ios/db.py | 60 +++ trueseeing/core/ios/model.py | 15 + trueseeing/core/ios/swift.py | 119 +++++ trueseeing/libs/ios/store.0.sql | 1 + trueseeing/libs/ios/store.1.sql | 2 + trueseeing/sig/ios/__init__.py | 0 trueseeing/sig/ios/base.py | 880 ++++++++++++++++++++++++++++++++ 11 files changed, 1432 insertions(+) create mode 100644 trueseeing/core/ios/__init__.py create mode 100644 trueseeing/core/ios/analyze.py create mode 100644 trueseeing/core/ios/context.py create mode 100644 trueseeing/core/ios/db.py create mode 100644 trueseeing/core/ios/model.py create mode 100644 trueseeing/core/ios/swift.py create mode 100644 trueseeing/libs/ios/store.0.sql create mode 100644 trueseeing/libs/ios/store.1.sql create mode 100644 trueseeing/sig/ios/__init__.py create mode 100644 trueseeing/sig/ios/base.py diff --git a/trueseeing/core/context.py b/trueseeing/core/context.py index 3283aa07..71c451da 100644 --- a/trueseeing/core/context.py +++ b/trueseeing/core/context.py @@ -62,6 +62,7 @@ def _init_formats(self) -> None: self._formats.update({ 'apk':dict(e=self._handle_apk, r=r'\.apk$', d='Android application package'), 'xapk':dict(e=self._handle_xapk, r=r'\.xapk$', d='Android appllication bundle'), + 'ipa': dict(e=self._handle_ipa, r=r'\.ipa$', d='iOS application archive'), }) for clazz in Extension.get().get_fileformathandlers(): @@ -77,6 +78,10 @@ def _handle_xapk(self, path: str) -> Optional[Context]: from trueseeing.core.android.context import XAPKContext return XAPKContext(path) + def _handle_ipa(self, path: str) -> Optional[Context]: + from trueseeing.core.ios.context import IPAContext + return IPAContext(path) + class Context(ABC): _path: str _excludes: List[str] diff --git a/trueseeing/core/ios/__init__.py b/trueseeing/core/ios/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/trueseeing/core/ios/analyze.py b/trueseeing/core/ios/analyze.py new file mode 100644 index 00000000..b3687fa6 --- /dev/null +++ b/trueseeing/core/ios/analyze.py @@ -0,0 +1,221 @@ +from __future__ import annotations +from typing import TYPE_CHECKING +from functools import cache +import os +import re + +if TYPE_CHECKING: + from typing import Iterable, Dict, Any, Iterator, Mapping, Tuple + +def _analyzed(x: str, tlds: re.Pattern[str]) -> Iterable[Dict[str, Any]]: + if '://' in x: + yield dict(type_='URL', value=re.findall(r'\S+://\S+', x)) + elif re.search(r'^/[{}$%a-zA-Z0-9_-]+(/[{}$%a-zA-Z0-9_-]+)+', x): + yield dict(type_='path component', value=re.findall(r'^/[{}$%a-zA-Z0-9_-]+(/[{}$%a-zA-Z0-9_-]+)+', x)) + elif re.search(r'^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+(:[0-9]+)?$', x): + m = re.search(r'^([^:/]+)', x) + if m: + hostlike = m.group(1) + components = hostlike.split('.') + if len(components) == 4 and all(re.match(r'^\d+$', c) for c in components) and all(int(c) < 256 for c in components): + yield dict(type_='possible IPv4 address', value=[hostlike]) + elif tlds.search(components[-1]): + if not re.search(r'^android\.(intent|media)\.|^os\.name$|^java\.vm\.name|^[A-Z]+.*\.(java|EC|name|secure)$', hostlike): + yield dict(type_='possible FQDN', value=[hostlike]) + +@cache +def _pat(c: str) -> re.Pattern[str]: + from io import StringIO + f = StringIO(c) + return re.compile('^(?:{})$'.format('|'.join(re.escape(l.strip()) for l in f if l and not l.startswith('#'))), flags=re.IGNORECASE) + +@cache +def _tlds() -> str: + from gzip import decompress + from base64 import b64decode + return decompress(b64decode(''' +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 +W5tc/+ytXbtu39/9Tqe8fh08b+v8fvnZD7ztK7cvqvX3y+W7/ujTb/bzWAtk+v5T+vo5yOv963Jwpx0e +bx/nnavtXEtsDqltX/Wl/nGX+sht6bv5/FnWbpeg7vJ8vn/vlV19i2/tad9T5+o/wtfd3mw+Ru8vf4Wy +f+bv8/te7s/VZ3W2n/Zy5Mf4cX7btciHk/3aN3k94/79UuPOmuvn4/xzaMp1uH+el5/H177z6xrP3z/7 +Um91fH59Nefn+77n2xwf52OX0NvH1y+9fJ0/2186v+0zDxzdvuyhqyr2Y5vva8mPHZL0qNrfDIL65q75 +/2Tf2/Ohqsp/1aeqmk5/C+yt/mrWv/3t+GWPY1e2ulTNn4z/sa/uPn/+mdpu/Vc3/xms+mmrOVR/sv3Z +fh2sJ1ufLP3r7k/efjWvJvxmXZzf0Yl3HJGjxL+72j3n3+w8XNo/C8tvTf9nYfl5+TPp871uTvbI+uf3 +z/nYGzq3vB+iPj7z/byfzMRCPuyr+c24H3dwh0x87hThy07c16/2L5VOJ/ycPb/dt8vB5/BWP973lQaW +3X63u02aOcX2l9oPdq7yz9fXvqi5q9Kp2fvNKW2HIbn/XLfqWPe9vtnzPB6Zp923EPv7+vO58zWGdfn+ +3reffv6KOzHDx8f73iZ/nqqPY625vj32vWV/f/953rf9cJdpmd/3iwFfYNTLQqU3Phf/qXP1/8vxE/6Z +H0dFf1/WnVru35/HtfN4j98fbvhu++Wj0ZPNUZjeTzt7Hh0CU/+Sw7HAZ3//uBxDPG/1xQ5v/eYOe6uC +ZnizH86ddsZtP4j8fSfbGN7959cuYRvydLHjuJ/oq43nYqGI9wmInjiPz+1lNlvrP+RmCeSAOJUEpJu8 +k6fZnNlCgwOt58QtdBZwCz2NjH70k4pLt2Wz0ell+fDqE4i9vvRf9YVrRCLkfI3mpbj+tbgSXL8e5n8B +fez0vAwoAAA= + ''')).decode('ascii') + +def analyze_url(path: str) -> Iterator[Mapping[str, Any]]: + def walker(path: str) -> Iterator[Tuple[str, bytes]]: + for dirpath, _, filenames in os.walk(path): + for fn in filenames: + target = os.path.join(dirpath, fn) + with open(target, 'rb') as f: + yield target, f.read() + + return analyze_url_in(walker(path)) + +def analyze_url_in(gen: Iterable[Tuple[str, bytes]]) -> Iterator[Mapping[str, Any]]: + tlds = _pat(_tlds()) + + pats = rb'|'.join([ + b"([a-z0-9A-Z]+://[^\\\"<>()' \\t\\n\\v\\r\\x00-\\x1f\\x80-\\xff]+)", + rb'"/[{}$%a-zA-Z0-9_-]+(/[{}$%a-zA-Z0-9_-]+)+', + rb'"[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+(:[0-9]+)?"', + ]) + + seen = set() + for n, s in gen: + for m in re.finditer(pats, s): + urllike = m.group(0).decode('latin1').strip('"') + if urllike not in seen: + seen.add(urllike) + for d in _analyzed(urllike, tlds): + for v in d['value']: + yield dict(fn=n, typ=d['type_'], v=v) + +def analyze_api(path: str) -> Iterator[Mapping[str, Any]]: + def walker(path: str) -> Iterator[Tuple[str, bytes]]: + for dirpath, _, filenames in os.walk(path): + for fn in filenames: + target = os.path.join(dirpath, fn) + with open(target, 'rb') as f: + yield target, f.read() + + return analyze_api_in(walker(path)) + +def analyze_api_in(gen: Iterable[Tuple[str, bytes]]) -> Iterator[Mapping[str, Any]]: + # XXX: focusing on oneline + pats = rb'^([_.].*?:[0-9a-f]{8}) +?[0-9a-f]+ +?b[a-z]* +(.*?);(\[[A-Za-z]+ .*?\]|undefined _.*)$' + blacklist = '|'.join([ + ' _objc_', + ' _swift_', + ' _OUTLINED_FUNCTION_[0-9]+', + ' __cxa_', + ' __swift_', + ' __release_weak', + ' ___cxa_', + ' ___swift_', + ' ___stack_chk_fail', + ' ___chkstk_darwin', + ]) + + for fn, s in gen: + for m in re.finditer(pats, s, flags=re.MULTILINE): + origin = m.group(1).decode('latin1').strip('"').replace(':','+') + target = m.group(2).decode('latin1').strip('"') + call = m.group(3).decode('latin1').strip('"') + if not re.search(blacklist, call): + if call.startswith('['): + lang = 'objc' + elif call.startswith('undefined _$'): + lang = 'swift' + elif call.startswith('undefined __Z'): + lang = 'cpp' + else: + lang = 'c' + + if 'EXTERNAL' in target: + yield dict(fn=fn, origin=origin, typ='API', lang=lang, call=call) + else: + yield dict(fn=fn, origin=origin, typ='private', lang=lang, call=call) + +def analyze_lib_needs_in(gen: Iterable[Tuple[str, bytes]]) -> Iterator[Mapping[str, Any]]: + pats = rb"(@rpath)?/[0-9A-za-z/.]+(\.framework/[0-9A-za-z/.]+|.dylib)" + + seen = set() + for n, s in gen: + for m in re.finditer(pats, s[:1048576]): + fnlike = m.group(0).decode('latin1').strip('"') + if fnlike not in seen: + seen.add(fnlike) + yield dict(fn=n, v=fnlike) + +def get_origin(n: str, l: bytes) -> Mapping[str, Any]: + pat = rb'(_.*?:[0-9a-f]{8}(?: |[0-9a-f]))[0-9a-f]+? +[a-z]+ ' + m = re.match(pat, l) + if m: + origin = m.group(1).decode('latin1').strip('"') + sect, offs = origin.split(':') + return dict(fn=n, sect=sect, offs=int(offs, 16)) + else: + raise ValueError() diff --git a/trueseeing/core/ios/context.py b/trueseeing/core/ios/context.py new file mode 100644 index 00000000..519aae23 --- /dev/null +++ b/trueseeing/core/ios/context.py @@ -0,0 +1,129 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +import os +import os.path + +from trueseeing.core.context import Context +from trueseeing.core.env import get_cache_dir +from trueseeing.core.ui import ui +from trueseeing.core.store import Store + +if TYPE_CHECKING: + from typing import List, Optional, Final, Set, TypedDict, Iterator, Any, Mapping + from sqlite3 import Connection + from trueseeing.core.context import ContextType + from trueseeing.core.db import FileEntry + from trueseeing.core.ios.db import IPAQuery, IPAStorePrep + + class Call(TypedDict): + path: str + sect: str + offs: int + priv: bool + swift: bool + objc: bool + cpp: bool + target: str + +class IPAContext(Context): + wd: str + excludes: List[str] + _path: str + _store: Optional['IPAStore'] = None + _type: Final[Set[ContextType]] = {'ipa', 'file'} + + def _get_type(self) -> Set[ContextType]: + return self._type + + def _get_workdir(self) -> str: + return os.path.join(get_cache_dir(), 'ts2-ios-{}'.format(self._get_fingerprint())) + + def _get_size(self) -> Optional[int]: + return os.stat(self._path).st_size + + def _get_fingerprint(self) -> str: + from hashlib import sha256 + with open(self._path, 'rb') as f: + return sha256(f.read()).hexdigest() + + async def _recheck_schema(self) -> None: + pass + + def store(self) -> 'IPAStore': + if self._store is None: + self._store = IPAStore(self.wd) + return self._store + + async def _analyze(self, level: int) -> None: + q: IPAQuery + if level > 0: + import plistlib + with self.store().query().scoped() as q: + from zipfile import ZipFile + with ZipFile(self._path, 'r') as zf: + def _decode(n: str, b: bytes) -> FileEntry: + if not n.endswith('Info,plist'): + return dict(path=n, blob=b, z=True) + else: + return dict(path=n, blob=plistlib.dumps(plistlib.loads(b)), z=True) + q.file_put_batch(_decode(i.filename, zf.read(i)) for i in zf.infolist() if not i.is_dir()) + + if level > 2: + tarpath = os.path.join(os.path.dirname(self._path), 'disasm.tar.gz') + if not os.path.exists(tarpath): + ui.fatal(f'prepare {tarpath}') + with self.store().query().scoped() as q: + import tarfile + with tarfile.open(tarpath) as tf: + q.file_put_batch(dict(path=f'disasm/{i.name}', blob=tf.extractfile(i).read(), z=True) for i in tf.getmembers() if (i.isreg() or i.islnk())) # type:ignore[union-attr] + + if level > 3: + ui.info('analyze: calls ...', nl=False) + from .analyze import analyze_api_in + + def _as_call(g: Iterator[Mapping[str, Any]]) -> Iterator[Call]: + for e in g: + typ = e['typ'] + lang = e['lang'] + sect, offs = e['origin'].split('+') + yield dict( + path=e['fn'], + sect=sect, + offs=int(offs.strip(), 16), + priv=(typ == 'private'), + swift=(lang == 'swift'), + objc=(lang == 'objc'), + cpp=(lang == 'cpp'), + target=e['call'] + ) + + q.call_add_batch(_as_call(analyze_api_in(q.file_enum('disasm/%')))) + ui.info('analyze: got {} calls'.format(q.call_count()), ow=True) + + +class IPAStore(Store): + def _prep_schema(self, o: Connection, is_creating: bool) -> None: + from trueseeing.core.db import FileTablePrep + IPAStorePrep(o).stage0() + if is_creating: + FileTablePrep(o).prepare() + IPAStorePrep(o).stage1() + + def _check_schema(self) -> None: + IPAStorePrep(self.db).require_valid_schema() + + @classmethod + def require_valid_schema_on(cls, path: str) -> None: + import os.path + store_path = os.path.join(path, cls._fn) + if not os.path.exists(store_path): + from trueseeing.core.exc import InvalidSchemaError + raise InvalidSchemaError() + else: + import sqlite3 + o = sqlite3.connect(store_path) + IPAStorePrep(o).require_valid_schema() + + def query(self) -> IPAQuery: + return IPAQuery(store=self) diff --git a/trueseeing/core/ios/db.py b/trueseeing/core/ios/db.py new file mode 100644 index 00000000..ef5437cd --- /dev/null +++ b/trueseeing/core/ios/db.py @@ -0,0 +1,60 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +from trueseeing.core.db import Query, StorePrep +from trueseeing.core.z import zd + +if TYPE_CHECKING: + from typing import Optional, Iterator, Iterable, Tuple + from trueseeing.core.ios.model import Call + +class IPAStorePrep(StorePrep): + def stage1(self) -> None: + super().stage1() + from importlib.resources import files + self.c.executescript((files('trueseeing')/'libs'/'ios'/'store.0.sql').read_text()) + + def stage2(self) -> None: + from importlib.resources import files + self.c.executescript((files('trueseeing')/'libs'/'ios'/'store.1.sql').read_text()) + + def _get_cache_schema_id(self) -> int: + return super()._get_cache_schema_id() ^ 0x00f28883 + +class IPAQuery(Query): + def file_enum(self, pat: Optional[str], patched: bool = False, regex: bool = False, neg: bool = False) -> Iterable[Tuple[str, bytes]]: + if pat is not None: + stmt0 = 'select path, z, blob from files where {neg} path {op} :pat'.format(neg='not' if neg else '', op=('like' if not regex else 'regexp')) + stmt1 = 'select path, A.z as z, coalesce(B.blob, A.blob) as blob from files as A full outer join patches as B using (path) where path {op} :pat'.format(op=('like' if not regex else 'regexp')) + for n, z, o in self.db.execute(stmt1 if patched else stmt0, dict(pat=pat)): + yield n, zd(o) if z else o + else: + stmt2 = 'select path, z, blob from files' + stmt3 = 'select path, A.z as z, coalesce(B.blob, A.blob) from files as A full outer join patches as B using (path)' + for n, z, o in self.db.execute(stmt3 if patched else stmt2): + yield n, zd(o) if z else o + + def call_add_batch(self, gen: Iterator[Call]) -> None: + stmt0 = 'insert into ncalls (priv, swift, cpp, objc, target, path, sect, offs) values (:priv, :swift, :cpp, :objc, :target, :path, :sect, :offs)' + self.db.executemany(stmt0, gen) + + def call_count(self) -> int: + stmt0 = 'select count(1) from ncalls' + for n, in self.db.execute(stmt0): + return n # type:ignore[no-any-return] + return 0 + + def calls(self, priv: bool = False, api: bool = False) -> Iterator[Call]: + stmt0 = 'select priv, swift, cpp, objc, target, path, sect, offs from ncalls' + stmt1 = 'select priv, swift, cpp, objc, target, path, sect, offs from ncalls where priv=:is_priv' + for priv, swift, cpp, objc, target, path, sect, offs in self.db.execute(stmt1 if (priv or api) else stmt0, dict(is_priv=priv)): + yield dict( + path=path, + sect=sect, + offs=offs, + priv=priv, + swift=swift, + objc=objc, + cpp=cpp, + target=target, + ) diff --git a/trueseeing/core/ios/model.py b/trueseeing/core/ios/model.py new file mode 100644 index 00000000..32d44fd7 --- /dev/null +++ b/trueseeing/core/ios/model.py @@ -0,0 +1,15 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import TypedDict + + class Call(TypedDict): + path: str + sect: str + offs: int + priv: bool + swift: bool + objc: bool + cpp: bool + target: str diff --git a/trueseeing/core/ios/swift.py b/trueseeing/core/ios/swift.py new file mode 100644 index 00000000..99161f05 --- /dev/null +++ b/trueseeing/core/ios/swift.py @@ -0,0 +1,119 @@ +from __future__ import annotations +from typing import TYPE_CHECKING +from contextlib import asynccontextmanager +from functools import cache +from os import read, write, close + +if TYPE_CHECKING: + from aiohttp import ClientSession + from typing import Protocol, Optional, AsyncIterator, AsyncContextManager, Tuple + from typing_extensions import Self + + class SwiftDemanglerImpl(Protocol): + def scoped(self) -> AsyncContextManager[Self]: ... + async def resolve(self, q: str) -> str: ... + +class SwiftDemangler: + @classmethod + def get(cls, simplify: bool = False) -> SwiftDemanglerImpl: + if _find_swift_demangler(): + return _Local(simplify) + else: + return _Remote(simplify) + +class _Local: + _f: Optional[Tuple[int, int]] + + def __init__(self, simplify: bool = False): + from asyncio import Lock, get_running_loop + from threading import Event + self._l = get_running_loop() + self._f = None + self._w = Event() + self._s = Lock() + self._simp = simplify + + @asynccontextmanager + async def scoped(self) -> AsyncIterator[Self]: + try: + yield self + finally: + if self._f: + for n in self._f: + close(n) + + async def _boot(self) -> None: + def _do() -> None: + from subprocess import Popen + from os import openpty + assert self._f is None + i0, i1 = openpty() + o0, o1 = openpty() + with Popen(self._get_cmdline(), shell=True, bufsize=0, stdin=i1, stdout=o1): + self._f = (i0, o0) + self._w.set() + self._w.clear() + self._f = None + + if not self._w.is_set(): + self._l.run_in_executor(None, _do) + self._w.wait() + + async def resolve(self, q: str) -> str: + async with self._s: + await self._boot() + assert self._f is not None + write(self._f[0], q.encode() + b'\n') + o = bytearray() + while not o.endswith(b'\n'): + o = o + read(self._f[1], 1024) + return o.rstrip().decode() + + def _get_cmdline(self) -> str: + path = _find_swift_demangler() + assert path + return '{}{}'.format( + path, + ' -simplified' if self._simp else '' + ) + +class _Remote: + _sess: Optional[ClientSession] = None + + def __init__(self, simplify: bool = False): + self._url = 'http://127.0.0.1:8000/{}'.format('s/' if simplify else '') + + @asynccontextmanager + async def scoped(self) -> AsyncIterator[Self]: + from aiohttp import ClientSession, ClientConnectionError + async with ClientSession() as sess: + try: + async with sess.get(self._url + 'a'): + pass + self._sess = sess + except ClientConnectionError: + from trueseeing.core.ui import ui + ui.warn('swift demangler is not available (try booting the demangler container and attaching it to the network)') + self._sess = None + yield self + self._sess = None + + async def resolve(self, q: str) -> str: + if self._sess: + async with self._sess.get(f'{self._url}{q}') as resp: + return (await resp.json())['to'] # type:ignore[no-any-return] + else: + return q + +@cache +def _find_swift_demangler() -> Optional[str]: + from subprocess import run, CalledProcessError + try: + if run('which swift-demangle', shell=True, capture_output=True).stdout: + return 'swift-demangle' + elif run('which swift', shell=True, capture_output=True).stdout: + return 'swift demangle' + else: + return None + except CalledProcessError: + return None diff --git a/trueseeing/libs/ios/store.0.sql b/trueseeing/libs/ios/store.0.sql new file mode 100644 index 00000000..4f141a59 --- /dev/null +++ b/trueseeing/libs/ios/store.0.sql @@ -0,0 +1 @@ +create table ncalls (nr integer primary key, priv boolean not null, swift boolean not null, cpp boolean not null, objc boolean not null, target varchar not null, path varchar not null, sect varchar not null, offs integer not null); diff --git a/trueseeing/libs/ios/store.1.sql b/trueseeing/libs/ios/store.1.sql new file mode 100644 index 00000000..0a4afed6 --- /dev/null +++ b/trueseeing/libs/ios/store.1.sql @@ -0,0 +1,2 @@ +-- indices +create index ncalls_sect on ncalls (sect); diff --git a/trueseeing/sig/ios/__init__.py b/trueseeing/sig/ios/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/trueseeing/sig/ios/base.py b/trueseeing/sig/ios/base.py new file mode 100644 index 00000000..62611b5e --- /dev/null +++ b/trueseeing/sig/ios/base.py @@ -0,0 +1,880 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +import math +import re +from trueseeing.api import Signature +from trueseeing.core.ui import ui + +if TYPE_CHECKING: + from typing import Dict, Optional, Mapping, Any, AnyStr, Set + from trueseeing.api import SignatureMap, SignatureHelper, ConfigMap + from trueseeing.core.ios.model import Call + from trueseeing.core.ios.db import IPAQuery + from trueseeing.core.ios.context import IPAContext + +class IOSDetector(Signature): + _cvss_info = 'CVSS:3.0/AV:P/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N/' + + def __init__(self, helper: SignatureHelper) -> None: + self._helper = helper + + @staticmethod + def create(helper: SignatureHelper) -> Signature: + return IOSDetector(helper) + + def get_sigs(self) -> SignatureMap: + return { + 'ios-nat-api':dict(e=self._detect_api, d='[iOS] Detects API call'), + 'ios-nat-urls':dict(e=self._detect_url, d='[iOS] Detects URL etc.'), + 'ios-detect-dyncode':dict(e=self._detect_dyncode, d='[iOS] Detects dynamic code exec attempt'), + 'ios-detect-syscall':dict(e=self._detect_inconsistent_syscall, d='[iOS] Detects syscalls looks inconsistent'), + 'ios-detect-reflection':dict(e=self._detect_reflection, d='[iOS] Detects possible reflections'), + 'ios-detect-jb':dict(e=self._detect_jb, d='[iOS] Detects possible JB probes'), + 'ios-detect-debug':dict(e=self._detect_debug, d='[iOS] Detects possible debug probes'), + 'ios-detect-privacy':dict(e=self._detect_privacy, d='[iOS] Detects privacy concerns'), + 'ios-detect-obfus':dict(e=self._detect_obfuscation, d='[iOS] Detects obfuscated functions'), + 'ios-detect-assert':dict(e=self._detect_assert, d='[iOS] Detects assertions'), + 'ios-detect-logging':dict(e=self._detect_log, d='[iOS] Detects log'), + 'ios-detect-lib-needed':dict(e=self._detect_needed_libs, d='[iOS] Detects needed libraries'), + 'ios-detect-motion':dict(e=self._detect_motion, d='[iOS] Detects use of motion/gyro etc.'), + 'ios-detect-urlscheme':dict(e=self._detect_urlscheme, d='[iOS] Detects recognized URL schemes'), + 'ios-detect-ats':dict(e=self._detect_ats, d='[iOS] Detects ATS status'), + 'ios-detect-permission':dict(e=self._detect_permission, d='[iOS] Detects protected resource accesses'), + 'ios-detect-req':dict(e=self._detect_device_req, d='[iOS] Detected device requirements'), + 'ios-detect-device':dict(e=self._detect_device_info, d='[iOS] Detects device info probes'), + 'ios-detect-ents':dict(e=self._detect_entitlements, d='[iOS] Detects entitlements'), + 'ios-detect-copyrights':dict(e=self._detect_copyrights, d='[iOS] Detects copyright banners'), + 'ios-detect-crypto-xor':dict(e=self._detect_crypto_xor, d='[iOS] Detects Vernum cipher usage with static keys'), + 'ios-detect-libs':dict(e=self._detect_libs, d='[iOS] Detects statically-linked libs'), + } + + def get_configs(self) -> ConfigMap: + return dict() + + def _get_ipa_context(self) -> IPAContext: + return self._helper.get_context().require_type('ipa') # type:ignore[return-value] + + def _format_aff0(self, c: Call) -> str: + return self._format_aff0_manual(c['path'], c['sect'], c['offs']) + + def _format_aff0_match(self, n: str, m: re.Match[AnyStr]) -> str: + return self._format_aff0_manual(n, '', m.start()) + + def _format_aff0_manual(self, n: str, s: str, o: int) -> str: + return '{} ({}+{:08x})'.format(n, s, o) + + async def _detect_api(self) -> None: + q: IPAQuery + context = self._get_ipa_context() + with context.store().query().scoped() as q: + from trueseeing.core.ios.swift import SwiftDemangler + async with SwiftDemangler.get(simplify=True).scoped() as dem: + for c in q.calls(): + priv, target, swift = c['priv'], c['target'], c['swift'] + if swift: + target = await dem.resolve(target) + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-nat-api', + cvss=self._cvss_info, + title='detected {} call'.format('private' if priv else 'API'), + info0=target, + aff0=self._format_aff0(c), + )) + + async def _detect_url(self) -> None: + from trueseeing.core.ios.analyze import analyze_url_in + q: IPAQuery + context = self._get_ipa_context() + with context.store().query().scoped() as q: + for d in analyze_url_in(q.file_enum('disasm/%', neg=True)): + tentative = False + if '...' in d['v']: + ui.warn('truncated value found; disassemble again with wider fields', onetime=True) + tentative = True + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-nat-urls', + cvss=self._cvss_info, + title='detected {}'.format(d['typ']), + cfd='tentative' if tentative else 'firm', + info0=d['v'], + aff0=d['fn'], + )) + + async def _detect_dyncode(self) -> None: + q: IPAQuery + context = self._get_ipa_context() + with context.store().query().scoped() as q: + for c in q.calls(): + if '_text' in c['sect']: + if not c['priv'] and '_dlopen' in c['target']: + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-dynload', + cvss=self._cvss_info, + title='dynamically loading code', + info0=c['target'], + aff0=self._format_aff0(c), + )) + + if re.search(r'VmStack|vm_stack|vm_err|StackPool|stack_pool|FunctionCopy|push_[if]64\(', c['target']): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-dynload', + cvss=self._cvss_info, + title='detected VM-like impl', + cfd='tentative', + info0=c['target'], + aff0=self._format_aff0(c), + )) + + async def _detect_inconsistent_syscall(self) -> None: + q: IPAQuery + context = self._get_ipa_context() + with context.store().query().scoped() as q: + for c in q.calls(api=True): + if '_text' in c['sect']: + if any((x in c['target']) for x in ['_fork', '_vfork', '_syscall']): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-syscall', + cvss=self._cvss_info, + title='inconsistent syscall', + info0=c['target'], + aff0=self._format_aff0(c), + )) + + async def _detect_reflection(self) -> None: + q: IPAQuery + context = self._get_ipa_context() + with context.store().query().scoped() as q: + for c in q.calls(api=True): + if '_text' in c['sect']: + if any((x in c['target']) for x in ['NSInvocation', '_NSClassFromString']): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-reflection', + cvss=self._cvss_info, + title='use of reflection', + cfd='tentative', + info0=c['target'], + aff0=self._format_aff0(c), + )) + elif '_class_add' in c['target']: + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-reflection', + cvss=self._cvss_info, + title='monkeypatching of class', + info0=c['target'], + aff0=self._format_aff0(c), + )) + + async def _detect_jb(self) -> None: + q: IPAQuery + context = self._get_ipa_context() + with context.store().query().scoped() as q: + for c in q.calls(api=True): + if '_text' in c['sect']: + if any((x in c['target']) for x in ['Jail', 'jailb']): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-jb', + cvss=self._cvss_info, + title='possible JB probe', + cfd='tentative', + info0=c['target'], + aff0=self._format_aff0(c), + )) + + async def _detect_debug(self) -> None: + q: IPAQuery + context = self._get_ipa_context() + with context.store().query().scoped() as q: + for c in q.calls(api=True): + if '_text' in c['sect']: + if any((x in c['target']) for x in ['Debuggi', 'debuggi']): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-debug', + cvss=self._cvss_info, + title='possible debug probe', + cfd='tentative', + info0=c['target'], + aff0=self._format_aff0(c), + )) + + async def _detect_privacy(self) -> None: + q: IPAQuery + context = self._get_ipa_context() + with context.store().query().scoped() as q: + for c in q.calls(): + mentioned = False + if '_text' in c['sect']: + if not c['priv']: + if ' uniqueDeviceIdentifer]' in c['target']: + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-privacy', + cvss=self._cvss_info, + title='privacy concern: getting {}'.format('UDID'), + info0=c['target'], + aff0=self._format_aff0(c), + )) + if ' uniqueGlobalDeviceIdentifer]' in c['target']: + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-privacy', + cvss=self._cvss_info, + title='privacy concern: getting {}'.format('UGDID'), + info0=c['target'], + aff0=self._format_aff0(c), + )) + elif ' identifierForVe' in c['target']: + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-privacy', + cvss=self._cvss_info, + title='privacy concern: getting {}'.format('IDFV'), + info0=c['target'], + aff0=self._format_aff0(c), + )) + elif ' resettable' in c['target']: + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-privacy', + cvss=self._cvss_info, + title='privacy concern: getting {}'.format('resettable device id'), + cfd='tentative', + info0=c['target'], + aff0=self._format_aff0(c), + )) + elif re.search(r' currentCarrier| carrierName\]', c['target']): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-privacy', + cvss=self._cvss_info, + title='privacy concern: getting {}'.format('carrier'), + info0=c['target'], + aff0=self._format_aff0(c), + )) + elif ' mobileCountryCode' in c['target']: + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-privacy', + cvss=self._cvss_info, + title='privacy concern: getting {}'.format('MCC'), + info0=c['target'], + aff0=self._format_aff0(c), + )) + elif ' mobileNetworkCode' in c['target']: + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-privacy', + cvss=self._cvss_info, + title='privacy concern: getting {}'.format('MNC'), + info0=c['target'], + aff0=self._format_aff0(c), + )) + elif ' freeDiskspace' in c['target']: + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-privacy', + cvss=self._cvss_info, + title='privacy concern: getting {}'.format('device info (diskspace)'), + info0=c['target'], + aff0=self._format_aff0(c), + )) + elif ' currencyCode' in c['target']: + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-privacy', + cvss=self._cvss_info, + title='privacy concern: getting {}'.format('user preference (currency)'), + info0=c['target'], + aff0=self._format_aff0(c), + )) + if re.search(r'\[GMSCoordinateBounds (isValid|(north|south)(West|East))\]|_CLLocation', c['target']): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-privacy', + cvss=self._cvss_info, + title='privacy concern: getting {}'.format('location'), + info0=c['target'], + aff0=self._format_aff0(c), + )) + elif re.search(r'pasteboard.*?change', c['target'], flags=re.IGNORECASE): + mentioned = True + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-privacy', + cvss=self._cvss_info, + title='privacy concern: watching pasteboard', + info0=c['target'], + aff0=self._format_aff0(c), + )) + elif re.search(r'(data|value)forpasteboard', c['target'], flags=re.IGNORECASE): + mentioned = True + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-privacy', + cvss=self._cvss_info, + title='privacy concern: reading pasteboard', + info0=c['target'], + aff0=self._format_aff0(c), + )) + elif re.search(r'setdata:.*?forpasteboardtype', c['target'], flags=re.IGNORECASE): + mentioned = True + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-privacy', + cvss=self._cvss_info, + title='privacy concern: writing pasteboard', + info0=c['target'], + aff0=self._format_aff0(c), + )) + + if 'Pasteboard' in c['target']: + if not mentioned: + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-privacy', + cvss=self._cvss_info, + title='privacy concern: referring pasteboard', + info0=c['target'], + aff0=self._format_aff0(c), + )) + elif '[ABKDevice ' in c['target']: + m = re.search(r'\[ABKDevice (.*?)\]', c['target']) + parts = m.group(1) if m else 'unknown' + if not parts.startswith('set'): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-privacy', + cvss=self._cvss_info, + title='privacy concern: getting {}'.format(f'device info ({parts} [ABKDevice])'), + info0=c['target'], + aff0=self._format_aff0(c), + )) + + async def _detect_obfuscation(self) -> None: + q: IPAQuery + context = self._get_ipa_context() + with context.store().query().scoped() as q: + for c in q.calls(): + if '_text' in c['sect']: + m = re.search(r' _[A-Z]+([a-z]{3,5}_[a-z]{2,4}(?:_[a-z]{2,6})?)\(', c['target']) + if m: + ent = self._entropy_of(m.group(1)) + if ent < 2.8 and not re.search(r' _BDP(mpi|rsa)', c['target']): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-obfus', + cvss=self._cvss_info, + title='possible obfuscated function', + cfd='tentative', + info0=c['target'], + info1=f'{ent:.04f}', + aff0=self._format_aff0(c), + )) + + async def _detect_assert(self) -> None: + q: IPAQuery + context = self._get_ipa_context() + with context.store().query().scoped() as q: + for c in q.calls(api=True): + if '_text' in c['sect']: + if '_assertionFailure_' in c['target']: + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-assert', + cvss=self._cvss_info, + title='possible live assertion check', + cfd='tentative', + info0=c['target'], + aff0=self._format_aff0(c), + )) + + async def _detect_log(self) -> None: + q: IPAQuery + context = self._get_ipa_context() + with context.store().query().scoped() as q: + for c in q.calls(api=True): + if '_text' in c['sect']: + if '_NSLog' in c['target']: + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-logging', + cvss=self._cvss_info, + title='detected logging', + cfd='tentative', + info0=c['target'], + aff0=self._format_aff0(c), + )) + + async def _detect_needed_libs(self) -> None: + from trueseeing.core.ios.analyze import analyze_lib_needs_in + q: IPAQuery + context = self._get_ipa_context() + with context.store().query().scoped() as q: + for d in analyze_lib_needs_in(q.file_enum('^disasm/|/_CodeSignature/', neg=True, regex=True)): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-lib-needed', + cvss=self._cvss_info, + title='detected library reference', + cfd='firm', + info0=d['v'], + aff0=d['fn'], + )) + + async def _detect_motion(self) -> None: + q: IPAQuery + context = self._get_ipa_context() + with context.store().query().scoped() as q: + for c in q.calls(api=True): + if '_text' in c['sect']: + if re.search(r'startGyro| (gyroUpdateInterval|rotationRate)\]', c['target']): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-motion', + cvss=self._cvss_info, + title='watching gyroscope', + cfd='firm', + info0=c['target'], + aff0=self._format_aff0(c), + )) + if re.search(r'startAccelero| (accelerometerUpdateInterval|(a|userA)cceleration)\]', c['target']): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-motion', + cvss=self._cvss_info, + title='watching accelerometer', + cfd='tentative', + info0=c['target'], + aff0=self._format_aff0(c), + )) + if re.search(r'startDeviceMotion| (deviceMotionUpdateInterval|gravity)\]', c['target']): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-motion', + cvss=self._cvss_info, + title='watching motion', + cfd='tentative', + info0=c['target'], + aff0=self._format_aff0(c), + )) + + async def _detect_urlscheme(self) -> None: + q: IPAQuery + from plistlib import loads + context = self._get_ipa_context() + with context.store().query().scoped() as q: + for n, b in q.file_enum('Payload/.*?.app/Info.plist', regex=True): + dom = loads(b) + if 'LSApplicationQueriesSchemes' in dom: + v = dom['LSApplicationQueriesSchemes'] + for scheme in v: + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-urlscheme', + cvss=self._cvss_info, + title='probing URL scheme', + cfd='firm', + info0=scheme, + aff0=n, + )) + if 'CFBundleURLTypes' in dom: + v = dom['CFBundleURLTypes'] + for d in v: + for scheme in d.get('CFBundleURLSchemes', []): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-urlscheme', + cvss=self._cvss_info, + title='handling URL scheme', + cfd='firm', + info0=scheme, + info1=d.get('CFBundleURLName', '(unknown name)'), + info2=d.get('CFBundleTypeRole', '(unknown role)'), + aff0=n, + )) + + async def _detect_ats(self) -> None: + q: IPAQuery + from plistlib import loads + context = self._get_ipa_context() + cvss0 = 'CVSS:3.0/AV:A/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N/' + cvss1 = 'CVSS:3.0/AV:A/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N/' + with context.store().query().scoped() as q: + for n, b in q.file_enum('Payload/.*?.app/Info.plist', regex=True): + dom = loads(b) + if 'NSAppTransportSecurity' in dom: + v = dom['NSAppTransportSecurity'] + if v.get('NSAllowsArbitraryLoads'): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-ats', + cvss=cvss0, + title='disabing ATS', + cfd='firm', + aff0=n, + )) + if v.get('NSAllowsArbitraryLoadsInWebContent'): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-ats', + cvss=cvss1, + title='disabling ATS for web content', + cfd='firm', + aff0=n, + )) + if v.get('NSAllowsArbitraryLoadsForMedia'): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-ats', + cvss=cvss1, + title='disabling ATS for media', + cfd='firm', + aff0=n, + )) + if v.get('NSAllowsLocalNetworking'): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-ats', + cvss=cvss1, + title='use of local networking', + cfd='firm', + aff0=n, + )) + if v.get('NSAllowsLocalNetworking'): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-ats', + cvss=cvss1, + title='use of local networking', + cfd='firm', + aff0=n, + )) + if v.get('NSExceptionDomains'): + for name, desc in v['NSExceptionDomains'].items(): + include_subdomains = desc.get('NSIncludeSubdomains', False) + if desc.get('NSExceptionAllowsInsecureHTTPLoads'): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-ats', + cvss=cvss1, + title='partially disabing ATS', + cfd='firm', + info0='{}{}'.format('*.' if include_subdomains else '', name), + aff0=n, + )) + else: + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-ats', + cvss=self._cvss_info, + title='partially enabling ATS', + cfd='firm', + info0='{}{}'.format('*.' if include_subdomains else '', name), + aff0=n, + )) + if desc.get('NSExceptionMinimumTLSVersion', 'TLSv1.3').lower() != 'tlsv1.3': + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-ats', + cvss=cvss1, + title='partial use of lower TLS version', + cfd='firm', + info0='{}{}'.format('*.' if include_subdomains else '', name), + info1=desc['NSExceptionMinimumTLSVersion'], + aff0=n, + )) + if not desc.get('NSExceptionRequiresForwardSecrecy'): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-ats', + cvss=cvss0, + title='partially allowing use of non-PFS ciphers', + cfd='firm', + info0='{}{}'.format('*.' if include_subdomains else '', name), + aff0=n, + )) + if not desc.get('NSRequiresCertificateTransparency'): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-ats', + cvss=self._cvss_info, + title='attempts to partially disable CT', + cfd='firm', + info0='{}{}'.format('*.' if include_subdomains else '', name), + aff0=n, + )) + if v.get('NSPinnedDomains'): + from base64 import b64decode + from binascii import hexlify + for name, desc in v['NSPinnedDomains'].items(): + include_subdomains = desc.get('NSIncludeSubdomains', False) + if desc.get('NSPinnedLeafIdentities'): + idents = desc['NSPinnedLeafIdentities'] + for ident in idents: + for algo, h in ident.items(): + m = re.fullmatch(r'SPKI-(.*?)-BASE64', algo) + if m: + algo = m.group(1) + h = hexlify(b64decode(h)).decode() + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-ats', + cvss=self._cvss_info, + title='detected TLS certificate pinning', + cfd='firm', + info0='{}{}'.format('*.' if include_subdomains else '', name), + info1='{} ({})'.format(h, algo), + aff0=n, + )) + + async def _detect_permission(self) -> None: + q: IPAQuery + from plistlib import loads + context = self._get_ipa_context() + with context.store().query().scoped() as q: + for n, b in q.file_enum('Payload/.*?.app/Info.plist', regex=True): + dom = loads(b) + + m = dict( + NSBluetoothAlwaysUsageDescription='persistent Bluetooth access', + NSBluetoothPeripheralUsageDescription='Bluetooth peripheral access', + NSCalendarsFullAccessUsageDescription='calendar read/write access', + NSCalendarsWriteOnlyAccessUsageDescription='calendar write access', + NSRemindersFullAccessUsageDescription='reminder read/write access', + NSCameraUsageDescription='camera access', + NSMicrophoneUsageDescription='microphone access', + NSContactsUsageDescription='contact read access', + NSFaceIDUsageDescription='FaceID access', + NSGKFriendListUsageDescription='GameCenter friend list access', + NSHealthClinicalHealthRecordsShareUsageDescription='clinical record read access', + NSHealthShareUsageDescription='HealthKit sample read access', + NSHealthUpdateUsageDescription='HealthKit sample write access', + NSHomeKitUsageDescription='HomeKit config read access', + NSLocationAlwaysAndWhenInUseUsageDescription='persistent location access', + NSLocationUsageDescription='location access', + NSLocationWhenInUseUsageDescription='foreground location access', + NSLocationTemporaryUsageDescriptionDictionary='temporary location access', + NSLocationAlwaysUsageDescription='persistent location access', + NSAppleMusicUsageDescription='media library access', + NSMotionUsageDescription='motion read access', + NSFallDetectionUsageDescription='fall detection read access', + NSLocalNetworkUsageDescription='local network access', + NSNearbyInteractionUsageDescription='nearby device interaction access', + NSNearbyInteractionAllowOnceUsageDescription='temporary nearby device interaction access', + NFCReaderUsageDescription='NFC read access', + NSPhotoLibraryAddUsageDescription='photo library append access', + NSPhotoLibraryUsageDescription='photo library read/write access', + NSUserTrackingUsageDescription='device tracking access', + NSSensorKitUsageDescription='sensor read access', + NSSiriUsageDescription='Siri integration access', + NSSpeechRecognitionUsageDescription='speech recognition access', + NSVideoSubscriberAccountUsageDescription='TV provider account access', + NSWorldSensingUsageDescription='world-sensing data access', + NSHandsTrackingUsageDescription='hand tracking data access', + NSIdentityUsageDescription='Wallet identity access', + NSCalendarsUsageDescription='calendar read/write access', + NSRemindersUsageDescription='reminder read/write access', + ) + + for k, res in m.items(): + v = dom.get(k) + if v: + if isinstance(v, dict): + for vk, vv in v.items(): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-permission', + cvss=self._cvss_info, + title=f'declaring {res}', + cfd='firm', + info0='{} ({})'.format(vv, vk), + aff0=n, + )) + else: + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-permission', + cvss=self._cvss_info, + title=f'declaring {res}', + cfd='firm', + info0=v, + aff0=n, + )) + + async def _detect_device_req(self) -> None: + q: IPAQuery + from plistlib import loads + context = self._get_ipa_context() + cvss0 = 'CVSS:3.0/AV:P/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N/' + with context.store().query().scoped() as q: + for n, b in q.file_enum('Payload/.*?.app/Info.plist', regex=True): + dom = loads(b) + d = dom.get('UISupportedDevices') + if d: + for k in d: + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-device', + cvss=self._cvss_info, + title='detected required device model', + cfd='firm', + info0=k, + aff0=n, + )) + d = dom.get('UIDeviceFamily') + if d: + for k in d: + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-device', + cvss=self._cvss_info, + title='detected targetted device family', + cfd='firm', + info0={1:'iPhone/iPod Touch', 2:'iPad/Mac Catalyst (for iPad)', 3:'Apple TV', 4:'Apple Watch', 6:'Mac Catalyst (for Mac)', 7:'Apple Vision'}.get(k, '(unknown)'), + aff0=n, + )) + d = dom.get('LSRequiresIPhoneOS') + if not d: + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-device', + cvss=cvss0, + title='insufficient device integrity check: iOS is not required', + cfd='tentative', + aff0=n, + )) + d = dom.get('MinimumOSVersion') + if d: + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-device', + cvss=self._cvss_info, + title='detected minimum OS version', + cfd='firm', + info0=d, + aff0=n, + )) + d = dom.get('UIRequiredDeviceCapabilities') + if d: + for k in ['arm64', 'armv7']: + if k in d: + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-device', + cvss=self._cvss_info, + title='detected required architecture', + cfd='firm', + info0=k, + aff0=n, + )) + + async def _detect_device_info(self) -> None: + q: IPAQuery + context = self._get_ipa_context() + with context.store().query().scoped() as q: + for c in q.calls(): + if '_text' in c['sect']: + if re.search(r' osName]', c['target']): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-device', + cvss=self._cvss_info, + title='getting device {}'.format('OS name'), + info0=c['target'], + aff0=self._format_aff0(c), + )) + if re.search(r' osVersion\]|OSVersion', c['target']) and not re.search(r'support|minim|atleast', c['target']): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-device', + cvss=self._cvss_info, + title='getting device {}'.format('OS version'), + info0=c['target'], + aff0=self._format_aff0(c), + )) + if re.search(r' (device)?model\]', c['target'], re.IGNORECASE): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-device', + cvss=self._cvss_info, + title='getting device {}'.format('model number'), + info0=c['target'], + aff0=self._format_aff0(c), + cfd='firm' if 'device' in c['target'] else 'tentative', + )) + if re.search(r'sysctl', c['target'], re.IGNORECASE): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-device', + cvss=self._cvss_info, + title='getting device {}'.format('kernel tunables'), + info0=c['target'], + aff0=self._format_aff0(c), + )) + + async def _detect_entitlements(self) -> None: + q: IPAQuery + context = self._get_ipa_context() + with context.store().query().scoped() as q: + for n, b in q.file_enum(r'Payload/(.*?).app/\1', regex=True): + ents = self._get_entitlements(b) + if ents: + for k, v in ents.items(): + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-ents', + cvss=self._cvss_info, + title='detected entitlement', + cfd='firm', + info0=k, + info1=repr(v), + aff0=n, + )) + + async def _detect_copyrights(self) -> None: + q: IPAQuery + context = self._get_ipa_context() + with context.store().query().scoped() as q: + for n, b in q.file_enum('Payload/%'): + for m in re.finditer(rb'(?:Copyright )?\(C\) [\x20-\xff]+', b, re.IGNORECASE): + banner = m.group(0) + if any((x in banner) for x in [b'You must retain', b'allow any third party to access', b' -> ']): + continue + m0 = re.match(rb'(.*)(?:<[a-z]+ ?/>||\*/)$', banner) + if m0: + banner = m0.group(1) + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-ents', + cvss=self._cvss_info, + title='detected copyright banner', + cfd='firm', + info0=banner.decode('latin1'), + aff0=self._format_aff0_match(n, m), + )) + + async def _detect_crypto_xor(self) -> None: + from trueseeing.core.ios.analyze import get_origin + q: IPAQuery + context = self._get_ipa_context() + with context.store().query().scoped() as q: + for n, b in q.file_enum('disasm/%.s'): + for m in re.finditer(rb'^.*? eor .*#(0x[0-9a-f]+)', b, re.MULTILINE): + mask = m.group(1) + if re.search(rb'^0x[124837cf]$|^0x[12483cf]0$|^0x[137bde2c]f$|00$|ff$|ffff|0000', mask): + continue + o = get_origin(n, m.group(0)) + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-crypto-xor', + cvss=self._cvss_info, + title='detected non-random XOR cipher', + cfd='tentative', + info0='0x{:02x}'.format(int(mask, 16)), + aff0=self._format_aff0_manual(o['fn'], o['sect'], o['offs']), + )) + + async def _detect_libs(self) -> None: + q: IPAQuery + context = self._get_ipa_context() + seen: Dict[str, Dict[str, Set[str]]] = dict(det=dict(), ref=dict()) + with context.store().query().scoped() as q: + for c in q.calls(): + if '_text' in c['sect']: + m = re.search(r'[\[ ]([A-Z]{2,})[A-Z][a-z]', c['target']) + if m: + prefix = m.group(1) + path = c['path'] + ref = not c['priv'] + sk = 'ref' if ref else 'det' + try: + lseen = seen[sk][path] + except KeyError: + lseen = set() + seen[sk][path] = lseen + if prefix not in lseen: + self._helper.raise_issue(self._helper.build_issue( + sigid='ios-detect-libs', + cvss=self._cvss_info, + title='detected library{}'.format(' ref' if ref else ''), + info0=prefix, + aff0=self._format_aff0(c), + )) + lseen.add(prefix) + + def _get_entitlements(self, app: bytes) -> Optional[Mapping[str, Any]]: + from plistlib import loads + m = re.search(rb'<\?xml [^\x00-\x08\x0b-\x1f\x80-\xff]+?', app, re.DOTALL) + if m: + return loads(m.group(0)) # type:ignore[no-any-return] + else: + return None + + @classmethod + def _entropy_of(cls, string: str) -> float: + o = 0.0 + m: Dict[str, int] = dict() + for c in string: + m[c] = m.get(c, 0) + 1 + for cnt in m.values(): + freq = float(cnt) / len(string) + o -= freq * (math.log(freq) / math.log(2)) + return o + + @classmethod + def _assumed_randomness_of(cls, string: str) -> float: + try: + return cls._entropy_of(string) / float(math.log(len(string)) / math.log(2)) + except ValueError: + return 0