blob: db872cda470ee3679eb5585097dd7e763a9017aa [file]
# Copyright (C) 2026 Free Software Foundation, Inc.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# A class which is a GDB frame unwinder. This frame unwinder can be
# used to corrupt the backtrace by tricking GDB into believing that
# there is a repeated frame-id in the backtrace.
#
# Sourcing this script creates a global called 'global_unwinder' and
# registers a global unwinder called 'stop-at-level'. The unwinder
# needs to be primed with the frame-ids copied from the current stack,
# this can be done by enabling the unwinder using the GDB command:
#
# (gdb) enable unwinder global stop-at-level
#
# You then need to set the level at which to corrupt the stack, e.g.:
#
# (gdb) python global_unwinder.stop_at_level = 3
#
# This will allow the frames #0, #1, #2, and #3 to print correctly,
# but frame #4 will appear to be a repeat of frame #3, so GDB will
# terminate the backtrace.
#
# As the unwinder relies on cached frame-ids, then resuming the
# inferior for which the frame-ids were cached will cause the unwinder
# to auto-disable itself, after which you'll need to re-enable the
# next time the inferior stops.
#
# Changing the 'global_unwinder.stop_at_level' will flush the frame
# cache, so you can test breaking the stack at different levels.
import re
import gdb
from gdb.unwinder import FrameId, Unwinder
class corrupt_stack_unwinder(Unwinder):
def __init__(self):
# Is this unwinder enabled or not? Accessed via the 'enabled'
# property.
self._enabled = True
# List of FrameId instances, one for each stack frame. This list is
# populated when this file is sourced into GDB.
self._frame_ids = []
# The inferior for which we cached self._frame_ids. The
# unwinder will only work within this inferior.
self._inferior = None
# At what stack level should we break the unwind?
self._stop_at_level = None
Unwinder.__init__(self, "stop-at-level")
if gdb.selected_inferior().pid > 0:
self._update_frame_id_cache()
else:
self._enabled = False
gdb.events.cont.connect(lambda ev: self._continued_event_handler(ev))
# Assume that the current inferior has a stack. Collect a FrameId
# object for each level of the current stack.
def _update_frame_id_cache(self):
self._frame_ids = []
self._inferior = gdb.selected_inferior()
frame = gdb.newest_frame()
while frame is not None:
# Get the frame-id in a verbose text form.
output = gdb.execute(
"maint print frame-id %d" % frame.level(), to_string=True
)
# Parse the frame-id in OUTPUT, find the stack and code addresses.
match = re.search(r"stack=(0x[0-9a-fA-F]+).*?code=(0x[0-9a-fA-F]+)", output)
if not match:
raise gdb.GdbError(
"Could not parse frame-id for frame #%d" % frame.level()
)
# Create the FrameId object.
sp_addr = int(match.group(1), 16)
pc_addr = int(match.group(2), 16)
self._frame_ids.append(FrameId(sp_addr, pc_addr))
frame = frame.older()
# Called when an inferior continues. If this is the inferior for
# which this unwinder collected the stack, then discard the
# collected FrameId objects and disable the unwinder.
def _continued_event_handler(self, event):
if gdb.selected_inferior() == self._inferior:
self._enabled = False
self._frame_ids = []
self._inferior = None
# To aid debugging, print the captured FrameId instances.
def print_frame_ids(self):
for level, fid in enumerate(self._frame_ids):
print(
"frame-id for frame #%s: {stack=0x%x,code=0x%x}"
% (level, fid.sp, fid.pc)
)
def __call__(self, pending_frame):
if self.stop_at_level is None or pending_frame.level() != self.stop_at_level:
return None
if gdb.selected_inferior() != self._inferior:
return None
if len(self._frame_ids) <= self.stop_at_level:
raise gdb.GdbError("not enough parsed frame-ids")
# Set the frame-id for this frame to its actual, expected
# frame-id, which we captured in the FRAME_IDS list.
unwinder = pending_frame.create_unwind_info(self._frame_ids[self.stop_at_level])
# Provide the register values for the caller frame, that is,
# the frame at 'STOP_AT_LEVEL + 1'.
#
# We forward all of the register values unchanged from this
# frame.
#
# What this means is that, as far as GDB is concerned, the
# caller frame will appear to be identical to this frame. Of
# particular importance, we send $pc and $sp unchanged to the
# caller frame.
#
# Because the caller frame has the same $pc and $sp as this
# frame, GDB will compute the same frame-id for the caller
# frame as we just supplied for this frame (above). This
# creates the artificial frame cycle which is the whole point
# of this test.
#
# NOTE: Forwarding all registers unchanged like this to the
# caller frame is not how you'd normally write a frame
# unwinder. Some registers might indeed be unmodified between
# frames, but we'd usually expect the $sp and/or the $pc to
# change. This test is deliberately doing something weird in
# order to force a cycle, and so test GDB.
for reg in pending_frame.architecture().registers("general"):
val = pending_frame.read_register(reg)
# Having unavailable registers leads to a fall back to the standard
# unwinders. Don't add unavailable registers to avoid this.
if str(val) == "<unavailable>":
continue
unwinder.add_saved_register(reg, val)
return unwinder
@property
def enabled(self):
return self._enabled
@enabled.setter
def enabled(self, val):
if val:
self._enabled = False
self._update_frame_id_cache()
self._enabled = val
@property
def stop_at_level(self):
return self._stop_at_level
@stop_at_level.setter
def stop_at_level(self, val):
self._stop_at_level = val
gdb.invalidate_cached_frames()
global_unwinder = corrupt_stack_unwinder()
gdb.unwinder.register_unwinder(None, global_unwinder, True)