diff --git a/README.md b/README.md index 999cc24..5ae5d80 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ Settings are stored in a file `.llef` located in your home directory formatted a | show_all_registers | Boolean | Enable/disable extended register output | | displayed_registers | List | Comma separated list of registers to display. Order is preserved. Use `default` as a placeholder for the built-in registers. e.g. `llefsettings set displayed_registers default,ymm0,ymm1` | | enable_darwin_heap_scan | Boolean | Enable/disable more accurate heap scanning for Darwin-based platforms. Uses the Darwin malloc introspection API, executing code in the address space of the target application using LLDB's evaluation engine | +| dereference_show_heap_boundaries | Boolean | Enable/disable heap allocation boundary separators in telescope/dereference (Darwin only, requires enable_darwin_heap_scan) | +| dereference_print | String | How the resolved end of a telescope/dereference chain is printed: `symbol` (symbol/string only), `pointer` (raw pointer only), or `both` (default, e.g. `0x1000004b0 ()`) | | max_trace_length | Int | Set the maximum length of the call stack backtrace to display | | stack_view_size | Int | Set the number of entries in the stack read to display | | max_disassembly_length | Int | Set the maximum number of instructions to disassemble and display around the current PC | @@ -150,6 +152,25 @@ e.g. 0x7fffffffecc8│+0000: 0x6857 ``` +#### Telescope + +Alias for `dereference`. View memory at consecutive addresses, with pointer values colored +according to the type of memory they point to (code/stack/heap): +``` +(lldb) telescope address [-l lines] +``` +Press enter to page forward through memory. The command detects when repeated and automatically advances: +``` +(lldb) telescope 0x7fffffffecc8 -l 8 +0x7fffffffecc8│+0000: 0x00007ffff7fc3000 -> ... +0x7fffffffecd0│+0008: 0x0000555555556004 -> ... +... +(lldb) [press enter] +0x7fffffffecd8│+0010: 0x... -> ... +``` +To show heap allocation boundaries (Darwin only), enable both `enable_darwin_heap_scan` and +`dereference_show_heap_boundaries` settings. + #### Context Refresh the LLEF GUI with: diff --git a/commands/dereference.py b/commands/dereference.py index ec37cd4..8731406 100644 --- a/commands/dereference.py +++ b/commands/dereference.py @@ -16,13 +16,22 @@ SBTarget, ) +from arch import I386, X86_64 from commands.base_command import BaseCommand from common.color_settings import LLEFColorSettings -from common.constants import GLYPHS, TERM_COLORS +from common.constants import GLYPHS, MSG_TYPE, TERM_COLORS from common.context_handler import ContextHandler -from common.output_util import color_string, output_line +from common.output_util import color_string, output_line, print_message +from common.settings import LLEFSettings from common.state import LLEFState -from common.util import attempt_to_read_string_from_memory, check_process, hex_int, hex_or_str, is_code, positive_int +from common.util import ( + attempt_to_read_string_from_memory, + check_process, + hex_int, + hex_or_str, + is_code_section, + positive_int, +) class DereferenceCommand(BaseCommand): @@ -31,12 +40,18 @@ class DereferenceCommand(BaseCommand): program: str = "dereference" container = None context_handler: Union[ContextHandler, None] = None + alias_set = {"telescope": ""} + last_address: Union[int, None] = None + last_base: Union[int, None] = None + last_lines: int = 10 + last_command: str = "" def __init__(self, debugger: SBDebugger, __: dict[Any, Any]) -> None: super().__init__() self.parser = self.get_command_parser() self.context_handler = ContextHandler(debugger) self.color_settings = LLEFColorSettings() + self.settings = LLEFSettings(debugger) self.state = LLEFState() @classmethod @@ -59,8 +74,11 @@ def get_command_parser(cls) -> argparse.ArgumentParser: ) parser.add_argument( "address", - type=hex_int, - help="A value/address/symbol used as the location to print the dereference from", + nargs="?", + default=None, + help="A value/address/symbol to print the dereference from. Accepts a hex/decimal literal, a" + " register/convenience variable (e.g. $rsp), a variable name, or an expression (e.g. nodes," + " &buf). If omitted, continues from last position.", ) return parser @@ -74,6 +92,26 @@ def get_long_help() -> str: """Return a longer help message""" return DereferenceCommand.get_command_parser().format_help() + def resolve_address(self, value: str, exe_ctx: SBExecutionContext) -> Union[int, None]: + """ + Resolve @value to an address. Plain hex/decimal literals are parsed directly; anything else + (a register, convenience variable, source variable name, or arbitrary expression) is resolved + via LLDB's expression evaluator in the context of the currently selected frame. + + :param value: The raw address argument as typed by the user. + :param exe_ctx: The current execution context. + :return: The resolved address, or None if @value could not be resolved. + """ + try: + return hex_int(value) + except ValueError: + pass + + address_value = exe_ctx.GetTarget().EvaluateExpression(value) + if address_value.GetError().Fail(): + return None + return address_value.GetValueAsUnsigned() + def read_instruction(self, target: SBTarget, address: int) -> SBInstruction: """ We disassemble an instruction at the given memory @address. @@ -83,9 +121,30 @@ def read_instruction(self, target: SBTarget, address: int) -> SBInstruction: :return: An object of the disassembled instruction. """ instruction_address = SBAddress(address, target) - instruction_list = target.ReadInstructions(instruction_address, 1, self.state.disassembly_syntax) + if self.context_handler.arch is I386 or self.context_handler.arch is X86_64: + instruction_list = target.ReadInstructions(instruction_address, 1, self.state.disassembly_syntax) + else: + instruction_list = target.ReadInstructions(instruction_address, 1) return instruction_list.GetInstructionAtIndex(0) + def read_symbol_name(self, target: SBTarget, address: int) -> Union[str, None]: + """ + Resolve @address to a or string, or None if no symbol is known. + + :param target: The target object file. + :param address: The memory address to resolve. + :return: A / string, or None. + """ + sb_address = SBAddress(address, target) + symbol = sb_address.symbol + if not symbol.IsValid(): + return None + name = symbol.GetName() + if name is None: + return None + offset = address - symbol.GetStartAddress().GetLoadAddress(target) + return f"<{name}+{offset}>" if offset else f"<{name}>" + def dereference_last_address( self, data: list[Union[int, str]], @@ -94,8 +153,8 @@ def dereference_last_address( regions: Union[SBMemoryRegionInfoList, None], ) -> None: """ - Memory data at the last address (second to last in @data list) is - either disassembled to an instruction or converted to a string or neither. + Resolve the last address (second to last in @data list) to a symbol, instruction or + string and render the end of the chain according to the `dereference_print` setting. :param data: List of memory addresses/data. :param target: The target object file. @@ -103,20 +162,51 @@ def dereference_last_address( :param regions: List of memory regions of the process. """ last_address = data[-2] - if isinstance(last_address, str): + # Skip pre-rendered markers such as "[LOOPING]"; there is nothing to resolve. + if isinstance(last_address, str) or isinstance(data[-1], str): return - if is_code(last_address, process, target, regions): - instruction = self.read_instruction(target, last_address) - if instruction.IsValid(): - data[-1] = color_string( - f"{instruction.GetMnemonic(target)}{instruction.GetOperands(target)}", - self.color_settings.instruction_color, - ) + # Resolve to a symbol or string. Only genuine code sections are disassembled, so + # const/data in an executable module segment is treated as a value. + annotation = None + if is_code_section(last_address, target): + symbol_name = self.read_symbol_name(target, last_address) + if symbol_name is not None: + # Colour the symbol like a binary pointer to match its address. + annotation = color_string(symbol_name, self.color_settings.code_color) + else: + instruction = self.read_instruction(target, last_address) + if instruction.IsValid(): + annotation = color_string( + f"{instruction.GetMnemonic(target)}{instruction.GetOperands(target)}", + self.color_settings.instruction_color, + ) else: string = attempt_to_read_string_from_memory(process, last_address) if string != "": - data[-1] = color_string(string, self.color_settings.string_color) + annotation = color_string(f'"{string}"', self.color_settings.string_color) + + if annotation is None: + return + + mode = self.settings.dereference_print + + # With no intermediate pointer hop (data[-2] is the address column itself), show the + # annotation alone, or leave the raw value in "pointer" mode. + if len(data) < 3: + if mode != "pointer": + data[-1] = annotation + return + + # Drop the raw bytes read through last_address and render the resolved pointer. + data.pop() + if mode == "symbol": + data[-1] = annotation + elif mode == "pointer": + pass # Leave the raw pointer in place; coloured by print_dereference_result. + else: # "both" + pointer_color = self.context_handler.pointer_type_color(last_address) + data[-1] = f"{color_string(hex_or_str(last_address), pointer_color)} ({annotation})" def dereference( self, address: int, target: SBTarget, process: SBProcess, regions: Union[SBMemoryRegionInfoList, None] @@ -157,7 +247,16 @@ def print_dereference_result(self, result: list[Union[int, str]], offset: int) - output += f"+0x{offset:04x}: " else: output += f"-0x{-offset:04x}: " - output += " -> ".join(map(hex_or_str, result[1:])) + + colored_chain = [] + for item in result[1:]: + if isinstance(item, int): + color = self.context_handler.pointer_type_color(item) + colored_chain.append(color_string(hex_or_str(item), color)) + else: + colored_chain.append(item) + + output += " -> ".join(colored_chain) output_line(output) @check_process @@ -172,12 +271,31 @@ def __call__( args = self.parser.parse_args(shlex.split(command)) - start_address = args.address - lines = args.lines - if args.base: - base = args.base + if args.address is None: + if DereferenceCommand.last_address is None: + print_message(MSG_TYPE.ERROR, "No address specified and no previous command to continue from") + return + address_size = exe_ctx.target.GetAddressByteSize() + start_address = DereferenceCommand.last_address + (address_size * DereferenceCommand.last_lines) + base = DereferenceCommand.last_base + lines = DereferenceCommand.last_lines else: - base = start_address + if command == DereferenceCommand.last_command and DereferenceCommand.last_address is not None: + address_size = exe_ctx.target.GetAddressByteSize() + start_address = DereferenceCommand.last_address + (address_size * DereferenceCommand.last_lines) + base = DereferenceCommand.last_base + lines = DereferenceCommand.last_lines + else: + resolved_address = self.resolve_address(args.address, exe_ctx) + if resolved_address is None: + print_message(MSG_TYPE.ERROR, f"Could not resolve address argument: {args.address}") + return + start_address = resolved_address + lines = args.lines + if args.base: + base = args.base + else: + base = start_address if self.context_handler is None: raise AttributeError("Class not properly initialised: self.context_handler is None") @@ -186,8 +304,29 @@ def __call__( address_size = exe_ctx.target.GetAddressByteSize() + allocation_map = {} + if self.settings.dereference_show_heap_boundaries: + from common.output_util import print_line + + allocation_map = self.context_handler.darwin_allocation_map(start_address, lines, address_size) + end_address = start_address + address_size * lines + previous_allocation = None + first_line = True + for address in range(start_address, end_address, address_size): + if self.settings.dereference_show_heap_boundaries and allocation_map: + current_allocation = allocation_map.get(address) + if not first_line and current_allocation != previous_allocation: + print_line() + previous_allocation = current_allocation + first_line = False + offset = address - base deref_result = self.dereference(address, exe_ctx.target, exe_ctx.process, self.context_handler.regions) self.print_dereference_result(deref_result, offset) + + DereferenceCommand.last_address = start_address + DereferenceCommand.last_base = base + DereferenceCommand.last_lines = lines + DereferenceCommand.last_command = command diff --git a/common/context_handler.py b/common/context_handler.py index f2a4e7b..4331281 100644 --- a/common/context_handler.py +++ b/common/context_handler.py @@ -42,6 +42,7 @@ from common.util import ( address_to_filename, attempt_to_read_string_from_memory, + find_darwin_allocation_sizes, find_darwin_heap_regions, find_stack_regions, get_frame_arguments, @@ -49,8 +50,8 @@ get_function_info_from_frame, get_registers, hex_or_str, - is_code, is_heap, + is_module_image, is_stack, ) @@ -220,6 +221,37 @@ def print_bytes(self, addr: int, size: int) -> None: output_line(line) + def pointer_type_color(self, value: int) -> Union[str, None]: + """Return color for @value based on whether it points to the binary image, stack or heap""" + if is_module_image(value, self.target): + return self.color_settings.code_color + if is_stack(value, self.regions, self.darwin_stack_regions): + return self.color_settings.stack_color + if is_heap(value, self.target, self.regions, self.darwin_stack_regions, self.darwin_heap_regions): + return self.color_settings.heap_color + return None + + def darwin_allocation_map(self, base_addr: int, count: int, step: int) -> dict[int, Union[int, None]]: + """Map addresses to their containing heap allocation start, for drawing separators between allocations""" + if LLEFState.platform != "Darwin" or not self.settings.enable_darwin_heap_scan: + return {} + + sizes = find_darwin_allocation_sizes(self.process, base_addr, count, step) + + allocation_map: dict[int, Union[int, None]] = {} + current_start: Union[int, None] = None + current_end = 0 + for i, size in enumerate(sizes): + addr = base_addr + i * step + if size > 0: + current_start = addr + current_end = addr + size + if current_start is not None and addr < current_end: + allocation_map[addr] = current_start + else: + allocation_map[addr] = None + return allocation_map + def print_register(self, register: SBValue) -> None: """Print details of a @register""" reg_name = register.GetName() @@ -232,14 +264,7 @@ def print_register(self, register: SBValue) -> None: # Register value has changed so highlight highlight = self.color_settings.modified_register_color - if is_code(reg_value, self.process, self.target, self.regions): - color = self.color_settings.code_color - elif is_stack(reg_value, self.regions, self.darwin_stack_regions): - color = self.color_settings.stack_color - elif is_heap(reg_value, self.target, self.regions, self.darwin_stack_regions, self.darwin_heap_regions): - color = self.color_settings.heap_color - else: - color = None + color = self.pointer_type_color(reg_value) formatted_reg_value = f"{reg_value:x}".ljust(12) line = color_string(reg_name.ljust(7), highlight, "", ": ") line += color_string(f"0x{formatted_reg_value}", color) diff --git a/common/expressions/darwin_get_malloc_zones.mm b/common/expressions/darwin_get_malloc_zones.mm index 4018686..7cf3322 100644 --- a/common/expressions/darwin_get_malloc_zones.mm +++ b/common/expressions/darwin_get_malloc_zones.mm @@ -51,16 +51,18 @@ typedef void (*vm_range_recorder_t)(unsigned int task, void *baton, unsigned int struct malloc_introspection_t *introspect; } malloc_zone_t; -// Information about memory regions to be returned to LLEF. -struct malloc_region { - uintptr_t lo_addr; - uintptr_t hi_addr; -}; +// All-uint64 => no padding ambiguity between JIT'd struct and struct.unpack +typedef struct $heap_region { uint64_t lo; uint64_t hi; } $heap_region; +typedef struct $heap_result { + uint64_t count; // entries written + uint64_t needed; // entries seen (needed > count => truncated) + uint64_t err; // kern_return_t, widened + $heap_region regions[MAX_MATCHES]; +} $heap_result; typedef struct callback_baton_t { range_callback_t callback; - unsigned int num_matches; - malloc_region matches[MAX_MATCHES + 1]; // Null terminate + $heap_result out; } callback_baton_t; // Memory read callback function. @@ -73,15 +75,13 @@ typedef void (*vm_range_recorder_t)(unsigned int task, void *baton, unsigned int // Callback to populate structure with low, high malloc addresses. range_callback_t range_callback = [](unsigned int task, void *baton, unsigned int type, uintptr_t ptr_addr, uintptr_t ptr_size) -> void { - callback_baton_t *lldb_info = (callback_baton_t *)baton; - // Upper limit for our array - if (lldb_info->num_matches < MAX_MATCHES) { - uintptr_t lo = ptr_addr; - uintptr_t hi = lo + ptr_size; - lldb_info->matches[lldb_info->num_matches].lo_addr = lo; - lldb_info->matches[lldb_info->num_matches].hi_addr = hi; - lldb_info->num_matches++; + callback_baton_t *info = (callback_baton_t *)baton; + if (info->out.count < MAX_MATCHES) { + info->out.regions[info->out.count].lo = ptr_addr; + info->out.regions[info->out.count].hi = ptr_addr + ptr_size; + ++info->out.count; } + ++info->out.needed; }; // Callback function from introspect enumerator function. @@ -98,22 +98,16 @@ typedef void (*vm_range_recorder_t)(unsigned int task, void *baton, unsigned int unsigned int num_zones = 0; unsigned int task = 0; -// Populate zones with pointer to a malloc_zone_t array representing heap zones. -int err = (int)malloc_get_all_zones(task, task_peek, &zones, &num_zones); - -// baton struct used to store data on heap regions between callbacks. -callback_baton_t baton = {range_callback, 0, {0}}; +callback_baton_t baton = { range_callback, { 0, 0, 0, {{0, 0}} } }; +baton.out.err = (uint64_t)(kern_return_t)malloc_get_all_zones(task, task_peek, &zones, &num_zones); -if (KERN_SUCCESS == err) { - // Enumerate over all heap zones. +if (baton.out.err == KERN_SUCCESS) { for (unsigned int i = 0; i < num_zones; ++i) { const malloc_zone_t *zone = (const malloc_zone_t *)zones[i]; - /* Introspection API will call our callback for each heap region (rather than each allocation as in - * malloc_info) */ if (zone && zone->introspect) zone->introspect->enumerator(task, &baton, MALLOC_PTR_REGION_RANGE_TYPE, (uintptr_t)zone, task_peek, range_recorder); } } -/* return the value */ -baton.matches \ No newline at end of file + +baton.out \ No newline at end of file diff --git a/common/settings.py b/common/settings.py index 08dfc8c..b906b7b 100644 --- a/common/settings.py +++ b/common/settings.py @@ -91,6 +91,16 @@ def truncate_output(self) -> bool: def enable_darwin_heap_scan(self) -> bool: return self._RAW_CONFIG.getboolean(self.GLOBAL_SECTION, "enable_darwin_heap_scan", fallback=False) + @property + def dereference_show_heap_boundaries(self) -> bool: + return self._RAW_CONFIG.getboolean(self.GLOBAL_SECTION, "dereference_show_heap_boundaries", fallback=False) + + @property + def dereference_print(self) -> str: + """How the resolved end of a dereference chain is printed: 'symbol', 'pointer' or 'both'.""" + mode = self._RAW_CONFIG.get(self.GLOBAL_SECTION, "dereference_print", fallback="both").lower() + return "both" if mode not in ("symbol", "pointer", "both") else mode + @property def go_support_level(self) -> str: support_level = self._RAW_CONFIG.get(self.GLOBAL_SECTION, "go_support_level", fallback="auto").lower() diff --git a/common/util.py b/common/util.py index b050542..eab7d2b 100644 --- a/common/util.py +++ b/common/util.py @@ -20,7 +20,7 @@ SBValue, eLanguageTypeObjC_plus_plus, eNoDynamicValues, - value, + eSectionTypeCode, ) from common.constants import DEFAULT_TERMINAL_COLUMNS, MAGIC_BYTES, MSG_TYPE, TERM_COLORS @@ -179,6 +179,22 @@ def is_in_section(address: int, target: SBTarget, target_section_name: str) -> b return target_section_name in full_section_name +def is_code_section(address: int, target: SBTarget) -> bool: + """ + Determines whether a given memory @address resides in a section that holds executable code. + + This distinguishes genuine code sections (e.g. '__text', '.text') from non-code sections that + share an executable segment (e.g. Mach-O '__cstring' / '__const'), which must not be disassembled. + + :param address: The memory address to check. + :param target: The target object file. + :return: A boolean of the check. + """ + sb_address = target.ResolveLoadAddress(address) + section = sb_address.GetSection() + return section.IsValid() and section.GetSectionType() == eSectionTypeCode + + def is_text_region(address: int, target: SBTarget, region: SBMemoryRegionInfo) -> bool: """ Determines if a given memory @address if within a '.text' section of the target executable. @@ -203,6 +219,20 @@ def is_text_region(address: int, target: SBTarget, region: SBMemoryRegionInfo) - return in_text +def is_module_image(address: int, target: SBTarget) -> bool: + """ + Determines whether an @address resides in a loaded module image (the main executable or a + shared library, any section), as opposed to anonymous mappings such as the stack or heap. + + :param address: The memory address to check. + :param target: The target object file. + :return: A boolean of the check. + """ + # Module-backed addresses have a real file behind them; anonymous regions do not. + module = SBAddress(address, target).GetModule() + return module.IsValid() and module.GetFileSpec().GetFilename() is not None + + def is_code(address: int, process: SBProcess, target: SBTarget, regions: Union[SBMemoryRegionInfoList, None]) -> bool: """Determines whether an @address points to code""" region = SBMemoryRegionInfo() @@ -453,46 +483,97 @@ def find_darwin_heap_regions(process: SBProcess) -> Union[list[tuple[int, int]], MAX_MATCHES = 128 - # Define Objective C++ code to be run as an LLDB expression. - - # Read template file, replace MAX_MATCHES value. common_dir = os.path.dirname(os.path.abspath(__file__)) expr_file_path = os.path.join(common_dir, "expressions", "darwin_get_malloc_zones.mm") with open(expr_file_path, "r") as expr_file: expr = expr_file.read().replace("{{MAX_MATCHES}}", str(MAX_MATCHES)) - # Return SBFrame stack frame object from current thread. frame = process.GetSelectedThread().GetSelectedFrame() - # Set options for evaluating Objective C++ code. expr_options = SBExpressionOptions() expr_options.SetIgnoreBreakpoints(True) expr_options.SetFetchDynamicValue(eNoDynamicValues) - # Set a 3 second timeout. expr_options.SetTimeoutInMicroSeconds(3 * 1000 * 1000) expr_options.SetTryAllThreads(False) expr_options.SetLanguage(eLanguageTypeObjC_plus_plus) + if hasattr(expr_options, "SetSuppressPersistentResult"): + expr_options.SetSuppressPersistentResult(True) expr_sbvalue = frame.EvaluateExpression(expr, expr_options) - match_value = value(expr_sbvalue) - heap_regions = [] - - # Populate heap regions from expression result. - if expr_sbvalue.error.Success(): - for count in range(MAX_MATCHES): - match_entry = match_value[count] - lo_addr = match_entry.lo_addr.sbvalue.unsigned - hi_addr = match_entry.hi_addr.sbvalue.unsigned - if lo_addr != 0: - heap_regions.append((lo_addr, hi_addr)) - else: - # Fallback to default way to calculate heap regions in error condition. + + if not expr_sbvalue.error.Success(): + return None + + error = SBError() + blob = expr_sbvalue.GetData().ReadRawData(error, 0, expr_sbvalue.GetByteSize()) + if error.Fail(): + return None + + import struct + + count, needed, kerr = struct.unpack_from(" count: + import logging + + logging.warning("heap region list truncated: %d of %d", count, needed) + + flat = struct.unpack_from("<%dQ" % (count * 2), blob, 24) + heap_regions = list(zip(flat[0::2], flat[1::2])) + + if not heap_regions: return None return heap_regions +def find_darwin_allocation_sizes(process: SBProcess, base_addr: int, count: int, step: int) -> list[int]: + """ + Return malloc_size for each of `count` addresses starting at @base_addr, advancing by @step. + + :return: A list of `count` sizes, or an empty list on failure. + """ + expr = ( + f"struct {{ uint64_t n; uint64_t sizes[{count}]; }} result;\n" + f"result.n = {count};\n" + f"for (uint64_t i = 0; i < {count}; ++i) {{\n" + f" result.sizes[i] = (uint64_t)malloc_size((const void *)({base_addr} + i * {step}));\n" + f"}}\n" + f"result" + ) + + frame = process.GetSelectedThread().GetSelectedFrame() + + expr_options = SBExpressionOptions() + expr_options.SetIgnoreBreakpoints(True) + expr_options.SetFetchDynamicValue(eNoDynamicValues) + expr_options.SetTimeoutInMicroSeconds(5 * 1000 * 1000) + expr_options.SetTryAllThreads(False) + expr_options.SetLanguage(eLanguageTypeObjC_plus_plus) + if hasattr(expr_options, "SetSuppressPersistentResult"): + expr_options.SetSuppressPersistentResult(True) + + expr_sbvalue = frame.EvaluateExpression(expr, expr_options) + if not expr_sbvalue.error.Success(): + return [] + + error = SBError() + blob = expr_sbvalue.GetData().ReadRawData(error, 0, expr_sbvalue.GetByteSize()) + if error.Fail(): + return [] + + import struct + + n = struct.unpack_from(" Union[list[int], None]: """ Parse the version string for an LLDB version built from the official LLVM sources