diff --git a/dissect/target/helpers/record.py b/dissect/target/helpers/record.py index d2821a2b1d..2f10aed733 100644 --- a/dissect/target/helpers/record.py +++ b/dissect/target/helpers/record.py @@ -161,6 +161,17 @@ def DynamicDescriptor(types: Sequence[str]) -> RecordDescriptor: COMMON_UNIX_FIELDS, ) +AndroidUserRecord = TargetRecordDescriptor( + "android/user", + [ + *COMMON_UNIX_FIELDS, + ("string", "usertype"), + ("datetime", "last_login"), + ("datetime", "last_foreground"), + ("varint", "flags"), + ], +) + EmptyRecord = RecordDescriptor( "empty", [], diff --git a/dissect/target/plugins/os/unix/linux/android/_os.py b/dissect/target/plugins/os/unix/linux/android/_os.py index 76bcae4061..b49a58119f 100644 --- a/dissect/target/plugins/os/unix/linux/android/_os.py +++ b/dissect/target/plugins/os/unix/linux/android/_os.py @@ -3,9 +3,10 @@ from typing import TYPE_CHECKING from dissect.target.helpers import configutil -from dissect.target.helpers.record import EmptyRecord +from dissect.target.helpers.record import AndroidUserRecord from dissect.target.plugin import OperatingSystem, export from dissect.target.plugins.os.unix.linux._os import LinuxPlugin +from dissect.target.plugins.os.unix.linux.android.util.abx import ABX, ABXSettingsFile if TYPE_CHECKING: from collections.abc import Iterator @@ -59,6 +60,19 @@ def create(cls, target: Target, sysvol: Filesystem) -> Self: @export(property=True) def hostname(self) -> str | None: + """Return the likely hostname of this Android device.""" + # Try the first user's ``device_name`` first + if (path := self.target.fs.path("/data/system/users/0/settings_global.xml")).is_file(): + try: + return ABXSettingsFile(path).get("device_name") + except ValueError: + pass + + # Try ``net.hostname`` + if net_hostname := self.props.get("net.hostname"): + return net_hostname + + # Fallback to ``ro.build.host`` property return self.props.get("ro.build.host") @export(property=True) @@ -82,9 +96,43 @@ def version(self) -> str: def os(self) -> str: return OperatingSystem.ANDROID.value - @export(record=EmptyRecord) - def users(self) -> Iterator[EmptyRecord]: - yield from () + @export(record=AndroidUserRecord) + def users(self) -> Iterator[AndroidUserRecord]: + """Yield all configured users of this Android system.""" + if not (users_dir := self.target.fs.path("/data/system/users")).is_dir(): + return + + for path in users_dir.iterdir(): + # Parse the /data/system/users/$id.xml ABX file for basic user information. + # Currently we do not parse /data/system/users/0/settings_(config|global|secure).xml + if not path.is_dir() or not (file := path.parent.joinpath(path.name + ".xml")).is_file(): + continue + try: + abx = ABX(file) + user = abx.tree.find(".") + except ValueError as e: + self.target.log.warning("Unable to parse %s: %s", file, e) + continue + + id = None + name = None + try: + id = user.attrib["id"] + name = user.find("./name").text + except Exception: + pass + + yield AndroidUserRecord( + name=name, + uid=id, + home=f"/data/media/{id}", + last_login=user.attrib["lastLoggedIn"] / 1000, + last_foreground=user.attrib["lastEnteredForeground"] / 1000, + flags=user.attrib["flags"], + usertype=user.attrib["type"], + source=file, + _target=self.target, + ) def find_build_props(fs: Filesystem) -> Iterator[Path]: diff --git a/dissect/target/plugins/os/unix/linux/android/util/__init__.py b/dissect/target/plugins/os/unix/linux/android/util/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/dissect/target/plugins/os/unix/linux/android/util/abx.py b/dissect/target/plugins/os/unix/linux/android/util/abx.py new file mode 100644 index 0000000000..faea399a71 --- /dev/null +++ b/dissect/target/plugins/os/unix/linux/android/util/abx.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import base64 +import struct +from enum import IntEnum +from pathlib import Path +from typing import TYPE_CHECKING, Any, BinaryIO +from xml.etree.ElementTree import Element, ElementTree, SubElement + +from dissect.cstruct import u16, u32, u64 + +if TYPE_CHECKING: + from collections.abc import Callable + + +class XmlType(IntEnum): + START_DOCUMENT = 0 + END_DOCUMENT = 1 + START_TAG = 2 + END_TAG = 3 + TEXT = 4 + CDSECT = 5 + ENTITY_REF = 6 + IGNORABLE_WHITESPACE = 7 + PROCESSING_INSTRUCTION = 8 + COMMENT = 9 + DOCDECL = 10 + ATTRIBUTE = 15 + + +class DataType(IntEnum): + NULL = 1 << 4 + STRING = 2 << 4 + STRING_INTERNED = 3 << 4 + BYTES_HEX = 4 << 4 + BYTES_BASE64 = 5 << 4 + INT = 6 << 4 + INT_HEX = 7 << 4 + LONG = 8 << 4 + LONG_HEX = 9 << 4 + FLOAT = 10 << 4 + DOUBLE = 11 << 4 + BOOLEAN_TRUE = 12 << 4 + BOOLEAN_FALSE = 13 << 4 + + +class ABX: + """Android binary XML (ABX) implementation. + + References: + - https://www.cclsolutionsgroup.com/post/android-abx-binary-xml + - https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/com/android/internal/util/BinaryXmlSerializer.java + - https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/com/android/internal/util/BinaryXmlPullParser.java + """ + + def __init__(self, path: Path | BinaryIO, *, to_str: bool = False) -> None: + if isinstance(path, Path): + self.path = path + self.fh = path.open("rb") + elif hasattr(path, "read"): + self.path = None + self.fh = path + else: + raise ValueError("Expected Path or file-like object") + + if (magic := self.fh.read(4)) != b"ABX\x00": + raise ValueError(f"Unexpected magic value {magic!r}") + + self.READ_MAP: dict[DataType, Callable] = { + DataType.NULL: lambda: None, + DataType.BOOLEAN_TRUE: lambda: True, + DataType.BOOLEAN_FALSE: lambda: False, + DataType.INT: lambda: u32(self.fh.read(4), endian="big", sign=True), + DataType.INT_HEX: lambda: f"{u32(self.fh.read(4), endian='big', sign=True):x}", + DataType.LONG: lambda: u64(self.fh.read(8), endian="big", sign=True), + DataType.LONG_HEX: lambda: f"{u64(self.fh.read(8), endian='big', sign=True):x}", + DataType.FLOAT: lambda: struct.unpack(">f", self.fh.read(4))[0], + DataType.DOUBLE: lambda: struct.unpack(">d", self.fh.read(8))[0], + DataType.BYTES_HEX: self._read_bytes, + DataType.BYTES_BASE64: lambda: base64.b64encode(self._read_bytes()).decode().strip(), + DataType.STRING: self._read_string, + DataType.STRING_INTERNED: self._read_string_interned, + } + + self.interned_strings = [] + self.tree = self.read(to_str=to_str) + + def __repr__(self) -> str: + return f"" + + def _read_token(self) -> tuple[XmlType, DataType]: + """Reads a byte to determine :class:`XmlType` and :class:`DataType`.""" + token = self.fh.read(1) + xml_type = XmlType(token[0] & 0x0F) # lower nibble + data_type = DataType(token[0] & 0xF0) # upper nibble + return xml_type, data_type + + def _read_bytes(self) -> bytes: + len = u16(self.fh.read(2), endian="big", sign=False) + return self.fh.read(len) + + def _read_string(self) -> str: + len = u16(self.fh.read(2), endian="big", sign=False) + return self.fh.read(len).decode() + + def _read_string_interned(self) -> str: + ref = u16(self.fh.read(2), endian="big", sign=True) + if ref == -1: + value = self._read_string() + self.interned_strings.append(value) + else: + value = self.interned_strings[ref] + return value + + def read(self, to_str: bool = False) -> ElementTree: + """Read the ABX file, returns XML :class:`ElementTree`.""" + elements: list[Element] = [] + document_open = False + root_closed = False + + # Start by placing everything in a root element. If we later discover the document + # only has one element in this root, we replace the root element with that element. + root = Element("root") + elements.append(root) + + while True: + xml_type, data_type = self._read_token() + + if xml_type == XmlType.START_DOCUMENT: + if data_type != DataType.NULL: + raise ValueError(f"XmlType.START_DOCUMENT should have DataType.NULL, got {data_type!r}") + document_open = True + + elif xml_type == XmlType.END_DOCUMENT: + if data_type != DataType.NULL: + raise ValueError(f"XmlType.END_DOCUMENT should have DataType.NULL, got {data_type!r}") + if len(elements) != 1: + raise ValueError(f"XmlType.END_DOCUMENT with unclosed elements ({elements!r}) at {self.fh.tell()}") + if not document_open: + raise ValueError(f"XmlType.END_DOCUMENT before XmlType.START_DOCUMENT at {self.fh.tell()}") + break + + elif xml_type == XmlType.START_TAG: + if data_type != DataType.STRING_INTERNED: + raise ValueError(f"XmlType.START_TAG should have DataType.STRING_INTERNED, got {data_type!r}") + if not document_open: + raise ValueError(f"XmlType.START_TAG before XmlType.START_DOCUMENT at {self.fh.tell()}") + if root_closed: + raise ValueError(f"XmlType.START_TAG after XmlType.END_TAG for root element at {self.fh.tell()}") + + name = self._read_string_interned() + if len(elements) == 0: + element = Element(name) + root = element + else: + element = SubElement(elements[-1], name) + elements.append(element) + + elif xml_type == XmlType.END_TAG: + if data_type != DataType.STRING_INTERNED: + raise ValueError(f"XmlType.END_TAG should have DataType.STRING_INTERNED, got {data_type!r}") + if len(elements) == 1: + raise ValueError(f"XmlType.END_TAG without any elements in stack at {self.fh.tell()}") + + name = self._read_string_interned() + + if name != (other := elements[-1].tag): + raise ValueError(f"XmlType.END_TAG for {name!r} encountered, expected for {other!r}") + + last = elements.pop() + if len(elements) == 0: + root_closed = True + root = last + + elif xml_type == XmlType.TEXT: + value = self._read_string() + if len(elements[-1]): + if len(value.strip()) == 0: + continue + raise ValueError(f"XmlType.TEXT with mixed content encountered at {self.fh.tell()}") + + if elements[-1].text is None: + elements[-1].text = value + else: + elements[-1].text += value + + elif xml_type == XmlType.ATTRIBUTE: + if len(elements) == 1: + raise ValueError(f"XmlType.ATTRIBUTE encountered outside any open element at {self.fh.tell()}") + + name = self._read_string_interned() + if name in elements[-1].attrib: + raise ValueError( + f"Duplicate XmlType.ATTRIBUTE {name} encountered for element {elements[-1]} at {self.fh.tell()}" + ) + + reader = self.READ_MAP.get(data_type) + if not reader: + raise ValueError(f"Unsupported DataType {data_type!r}") + + value = reader() + elements[-1].attrib[name] = str(value) if to_str else value + + if not (root_closed or (len(elements) == 1 and elements[0] is root)): + raise ValueError("Document contains unclosed elements") + + # If the root we created at the start only contains one child, we make that node the root. + if len(children := root.findall("./")) == 1: + root = children[0] + + return ElementTree(root) + + +class ABXSettingsFile(ABX): + """Android binary ABX settings file parser.""" + + def get(self, key: str, *, value_only: bool = True) -> Any: + """Return the value of the given setting name.""" + if (node := self.tree.find(f"./settings/setting[@name='{key}']")) is not None: + return node.attrib["value"] if value_only else node + + return None + + def get_node(self, key: str) -> Any: + return self.get(key, value_only=False) diff --git a/dissect/target/plugins/os/unix/linux/android/util/xml.py b/dissect/target/plugins/os/unix/linux/android/util/xml.py new file mode 100644 index 0000000000..a56326cb83 --- /dev/null +++ b/dissect/target/plugins/os/unix/linux/android/util/xml.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import BinaryIO +from xml.etree.ElementTree import ElementTree + +from defusedxml import ElementTree as ET + +from dissect.target.plugins.os.unix.linux.android.util.abx import ABX + + +def read_android_xml(fh: BinaryIO) -> ElementTree: + """Convert a readable stream of bytes which might be plaintext XML or ABX to an ElementTree.""" + offset = fh.tell() + magic = fh.read(4) + fh.seek(offset) + + if magic == b"ABX\x00": + return ABX(fh).tree + + if magic == b" bool: return False + @arg("path", type=TargetPathArgument) + def cmd_abx(self, args: argparse.Namespace, stdout: TextIO) -> bool: + """Print the contents of an Android ABX file.""" + paths = list(self.resolve_glob_path(args.path)) + for path in paths: + if len(paths) > 1: + print(path) + try: + abx = ABX(path, to_str=True) + pprint(ElementTree.tostring(abx.tree.getroot()).decode(), stream=stdout) + except ValueError as e: + print(f"Failed to parse file {path}: {e}") + continue + + return False + @arg("path", type=TargetPathArgument) @alias("digest") @alias("shasum") diff --git a/tests/_data/plugins/os/unix/linux/android/users/0.xml b/tests/_data/plugins/os/unix/linux/android/users/0.xml new file mode 100644 index 0000000000..c8106282bf --- /dev/null +++ b/tests/_data/plugins/os/unix/linux/android/users/0.xml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:05ea37edfffaa86aaa1586bb5a0419c857f74d50425c06a786899e61f8d201d5 +size 419 diff --git a/tests/_data/plugins/os/unix/linux/android/users/settings_global.dec b/tests/_data/plugins/os/unix/linux/android/users/settings_global.dec new file mode 100644 index 0000000000..a933f07240 --- /dev/null +++ b/tests/_data/plugins/os/unix/linux/android/users/settings_global.dec @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2df3bb55166b81ee8e8c5a3929794c0fe4b68112119ead2da3cbefe5d558eda7 +size 33457 diff --git a/tests/_data/plugins/os/unix/linux/android/users/settings_global.xml b/tests/_data/plugins/os/unix/linux/android/users/settings_global.xml new file mode 100644 index 0000000000..f6f8be22a0 --- /dev/null +++ b/tests/_data/plugins/os/unix/linux/android/users/settings_global.xml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ae8f916b3b4956141ca7a74c5dc7313150cbd5f6555fef6f63ba8de916a1f66 +size 22340 diff --git a/tests/_data/plugins/os/unix/linux/android/users/userlist.xml b/tests/_data/plugins/os/unix/linux/android/users/userlist.xml new file mode 100644 index 0000000000..ce32a85608 --- /dev/null +++ b/tests/_data/plugins/os/unix/linux/android/users/userlist.xml @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:75dac348b3c4014e6e1573717011ba3da7a89a78fc5758cc0e758e7f0905ad69 +size 267 diff --git a/tests/plugins/os/unix/linux/android/test__os.py b/tests/plugins/os/unix/linux/android/test__os.py index 0db12e57b9..7f6718bc81 100644 --- a/tests/plugins/os/unix/linux/android/test__os.py +++ b/tests/plugins/os/unix/linux/android/test__os.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime, timezone from io import BytesIO from typing import TYPE_CHECKING @@ -14,6 +15,7 @@ def test_android_os(target_android: Target) -> None: + """Test if we detect Android and the version correctly.""" target_android.add_plugin(AndroidPlugin) assert target_android.os == "android" @@ -60,3 +62,28 @@ def test_android_os_detect_props(target_bare: Target, build_prop_locations: list # test if glob does not go too deep. assert "/foo/bar/too/deep/build.prop" not in target_bare._os.build_prop_paths + + +def test_android_os_users(target_android: Target, fs_android: VirtualFilesystem) -> None: + """Test if we detect and parse Android users correctly.""" + fs_android.map_file( + "/data/system/users/userlist.xml", absolute_path("_data/plugins/os/unix/linux/android/users/userlist.xml") + ) + fs_android.map_file( + "/data/system/users/0/settings_global.xml", + absolute_path("_data/plugins/os/unix/linux/android/users/settings_global.xml"), + ) + fs_android.map_file("/data/system/users/0.xml", absolute_path("_data/plugins/os/unix/linux/android/users/0.xml")) + target_android.add_plugin(AndroidPlugin) + + records = list(target_android.users()) + assert len(records) == 1 + assert records[0].hostname == "Pixel 7a" + assert records[0].name == "Liz" + assert records[0].uid == 0 + assert records[0].home == "/data/media/0" + assert records[0].source == "/data/system/users/0.xml" + assert records[0].usertype == "android.os.usertype.full.SYSTEM" + assert records[0].last_login == datetime(2024, 7, 28, 3, 30, 36, 703000, tzinfo=timezone.utc) + assert records[0].last_foreground == datetime(2024, 7, 28, 3, 30, 26, 264000, tzinfo=timezone.utc) + assert records[0].flags == 19475 diff --git a/tests/plugins/os/unix/linux/android/util/__init__.py b/tests/plugins/os/unix/linux/android/util/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/plugins/os/unix/linux/android/util/test_abx.py b/tests/plugins/os/unix/linux/android/util/test_abx.py new file mode 100644 index 0000000000..453b31ebd0 --- /dev/null +++ b/tests/plugins/os/unix/linux/android/util/test_abx.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from defusedxml import ElementTree + +from dissect.target.plugins.os.unix.linux.android.util.abx import ABX +from tests._utils import absolute_path + + +def test_abx_simple() -> None: + """Test if we can parse a simple ABX file.""" + file = absolute_path("_data/plugins/os/unix/linux/android/users/userlist.xml") + abx = ABX(file, to_str=True) + + assert ElementTree.tostring(abx.tree.getroot()).decode() == ( + '' + "" + '' # noqa: E501 + "" + '' + "" + ) + + +def test_abx_multiple_root_elements() -> None: + """Test if we can parse an ABX file with multiple root elements.""" + file = absolute_path("_data/plugins/os/unix/linux/android/users/settings_global.xml") + abx = ABX(file, to_str=True) + + assert ( + ElementTree.tostring(abx.tree.getroot()).decode() + == absolute_path("_data/plugins/os/unix/linux/android/users/settings_global.dec").read_text() + )