Skip to content

Suspected X64 exception handler IP to state map off-by-one error #117725

Open
@rveldhoven

Description

@rveldhoven

This issue may be upstream in LLVM, but since it's deep and complicated, I can't exactly pinpoint it. Since I don't have LLVM installed, I can only file against rust. Finally I don't know for 100% sure if this is a bug, although I think it should be due to security considerations.

On Windows, in both release and debug builds, there seems to be an off-by-one error in the IP to state map table used during X64 Cxx3 exception unwinding. The issue always seems to occur on the last entry in the IP to state map.

The IP to state map is used during exception handling and x64 stack unwinding to determine where in a function an exception has occurred, and which exception handlers or terminators must be called and what local variables are allocated on the stack at that time.

Certain entries appear to end in the first byte of an instruction, this makes no sense. The addresses do not lie between instruction boundaries and the bytes at the addresses specified do not always disassemble to valid instructions.

If I try very hard I can almost see this being a security vulnerability. If the exception handler determines the wrong state it may cause memory to be accessed by handlers that is not in use, as the wrong handler could be called due to the exception address being on the first byte of the next instruction. I can't repro this with a crash, and any viable exploitation would need some exceedingly exotically structured instructions, but it does not seem impossible. I don't think this constitutes an actual security issue because I don't think such a weirdly structured program exists.

I expected to see this happen: I expect the IP entries in the IP to state map to lie on instruction boundaries.

Instead, this happened: Some entries in the IP to state map lie in the first byte of instructions.

The following data structures need to be traversed to get to the problem:

PE header -> .pdata -> [RUNTIME_ENTRIES] -> _UNWIND_INFO-> _s_FuncInfo-> [IptoStateMapEntry]

I've attached a rather large program to showcase this, since nothing crashes or otherwise misbehaves the program points out wrong entries in itself.

Note that the program uses a simple heuristic, instead of a full disassembler, to check a single type of instruction I saw while compiling the debug version of this program. If it does not halt on a "suspected faulty entry", you should try a few of the addresses indicated on "CHECKMECHECKMECHECKME" in a debugger.

cargo.toml

[package]
name = "sehtest"
version = "0.1.0"
edition = "2021"

[dependencies]


[dependencies.windows-sys]
version = "0.36.1"
features = [
    "Win32_System_Memory",
    "Win32_Foundation",
    "Win32_Security",
    "Win32_System_Threading",
    "Win32_System_Pipes",
    "Win32_Storage_FileSystem",
    "Win32_System_IO",
    "Win32_System_Diagnostics_Debug",
    "Win32_System_SystemServices",
    "Win32_System_LibraryLoader",
    "Win32_System_SystemInformation",
]

main.rs

use windows_sys::Win32::System::Diagnostics::Debug::{ImageDirectoryEntryToData, IMAGE_DIRECTORY_ENTRY_EXCEPTION, UNW_FLAG_UHANDLER, UNW_FLAG_EHANDLER};
use windows_sys::Win32::System::LibraryLoader::LoadLibraryA;
use windows_sys::Win32::System::{SystemServices::IMAGE_DOS_HEADER, Diagnostics::Debug::IMAGE_RUNTIME_FUNCTION_ENTRY};

#[repr(C)]
pub struct SFuncInfo
{
    magic_number_and_bbt: u32,
    max_state: i32,
    disp_unwind_map: i32,
    n_try_blocks: u32,
    disp_try_block_map: i32,
    n_ip_map_entries: u32,
    disp_ip_to_state_map: i32,
    disp_uwind_help: i32,
    disp_es_type_list: i32,
    eh_flags: i32,
}

/*
typedef struct IptoStateMapEntry {
    unsigned int	Ip;		// Image relative offset of IP
    __ehstate_t		State;
} IptoStateMapEntry;
*/

#[repr(C)]
pub struct SIptoStateMapEntry
{
    ip: u32,
    state: u32,
}

// This functions returns a list of all functions in the binary that are protected by SEH
// It does this by scanning the .pdata section for all functions that have a valid SEH handler
pub fn peparser_get_x64_seh_runtime_functions(
    pe_ptr: *const IMAGE_DOS_HEADER,
) -> Vec<IMAGE_RUNTIME_FUNCTION_ENTRY>
{
    let mut seh_functions: Vec<IMAGE_RUNTIME_FUNCTION_ENTRY> = Vec::new();

    // Get the .pdata section
    let mut ulsize: u32 = 0;

    let mut ptr_runtime_functions: *mut IMAGE_RUNTIME_FUNCTION_ENTRY = unsafe {
        ImageDirectoryEntryToData(
            pe_ptr as *mut _,
            1u8,
            IMAGE_DIRECTORY_ENTRY_EXCEPTION,
            &mut ulsize,
        ) as *mut _
    };

    if ptr_runtime_functions.is_null()
    {
        return seh_functions;
    }

    // Loop through all entries
    loop
    {
        unsafe {
            let ptr_runtime_function: *mut IMAGE_RUNTIME_FUNCTION_ENTRY = ptr_runtime_functions;

            // Check if the SEH handler is valid
            if (*ptr_runtime_function).BeginAddress != 0
            {
                seh_functions.push(*ptr_runtime_function);
            }
            else
            {
                break;
            }

            // Go to the next entry
            ptr_runtime_functions = ptr_runtime_functions.add(1);
        }
    }

    seh_functions
}

pub fn is_exception_data_cxx_s_fun_info(
    module_base: u64,
    pointer_to_check: *const u8,
) -> bool
{
    let fun_table_rva = unsafe { *(pointer_to_check as *const u32) };

    if fun_table_rva == u32::MAX || fun_table_rva == 0
    {
        return false;
    }

    let fun_table_va = module_base + fun_table_rva as u64;

    let unsupported_magic_numbers = [
        0x19930520, // VC6
        0x19930521, // VC7
    ];

    if unsupported_magic_numbers.contains(&fun_table_va)
    {
        panic!("Unsupported magic number (VC6 or VC7): {:x}", fun_table_va)
    }

    let magic_numbers = [
        0x19930522, // VC8 t/m 2019
                   // Fh4 does not use the magic number any more...
                   // TODO: implement a check on the handelr VA to see if it points to __CxxFrameHandler4 or __CxxFrameHandler3 to distinguish between VC8 and newer
    ];

    // Check if the fun table pointer is the magic number
    unsafe {
        let magic_number = *(fun_table_va as *const u32);

        magic_numbers.contains(&magic_number)
    }
}

fn main()
{
    // Get path of the current executable
    let path = std::env::current_exe().unwrap();

    // Call load library on itself using windows_sys
    let ptr_self = unsafe
    {
        let c_string_path = std::ffi::CString::new(path.to_str().unwrap()).unwrap();

        LoadLibraryA(c_string_path.as_bytes_with_nul().as_ptr() as *const u8)
    };

    if ptr_self == 0
    {
        panic!("Failed to load self")
    }

    // This gets a lits of all runtime function entries for the current binary
    // The first value specifies the RVA start of a function, the next the RVA end of the function. The last value is the RVA of the unwind info
    let mut runtime_functions = peparser_get_x64_seh_runtime_functions(ptr_self as *const _);

    runtime_functions.sort_unstable_by(|a, b| {
        a.BeginAddress
            .cmp(&b.BeginAddress)
    });

    for runtime_function in runtime_functions
    {
        println!("BeginAddress: {:x}", runtime_function.BeginAddress);
        println!("EndAddress: {:x}", runtime_function.EndAddress);
        unsafe 
        { 
            println!("UnwindInfoAddress: {:x}", runtime_function.Anonymous.UnwindInfoAddress);

            /*
            unwind info, which looks like this:
            typedef struct _UNWIND_INFO {
                unsigned char Version : 3;
                unsigned char Flags : 5;
                unsigned char SizeOfProlog;
                unsigned char CountOfCodes;
                unsigned char FrameRegister : 4;
                unsigned char FrameOffset : 4;
                UNWIND_CODE UnwindCode[1];
            /*  UNWIND_CODE MoreUnwindCode[((CountOfCodes+1)&~1)-1];
            *  union {
            *      OPTIONAL unsigned long ExceptionHandler;
            *      OPTIONAL unsigned long FunctionEntry;
            *  };
            *  OPTIONAL unsigned long ExceptionData[];
            */
            } UNWIND_INFO, * PUNWIND_INFO;
            */

            /*
            Unwind info comes in a few flavors, but the bug is in the one where an exception, termination or "coiled" handler is used.
            If this is the case, then the code continues the search, otherwise it continues to the next runtime function.

            The data we're _actually_ intrested in lies in the optional exception data field, which is a pointer to a cxx_s_fun_info struct.
            And _only_ if the exception data is a pointer to a _SFunctionInfo for VC8 and newer, or a _SFunctionInfo for VC6 and VC7.

            Visual studio 2022 now also has support for a new version, but I don't have a binary to test it on.
            */

            // Check if an exception handler is present
            if runtime_function.Anonymous.UnwindInfoAddress == 0
            {
                continue;
            }

            // If there is, get the unwind info with the base_address + the RVA of the unwind info
            let ptr_unwind_info: *mut u8 = (ptr_self as usize
                + runtime_function.Anonymous.UnwindInfoAddress as usize)
                as *mut _;

            // The flags field is a bitfield, where the first 3 bits are the version and the next 5 bits are the flags
            let flags = ((*ptr_unwind_info >> 3) & 0x1f) as u32;
            // We need to get the count of unwind codes to calculate the offset of the handler or data
            let count_of_unwind_codes = (*ptr_unwind_info.offset(2)) as u32;

            // The offset of the handler or data is 4 + (2 * ((count_of_unwind_codes + 1) & !1)) as windows always defines at least 1 unwind code being present
            let offset_of_handler_or_data = 4 + (2 * ((count_of_unwind_codes + 1) & !1)) as isize;

            // Handler !is termination, exception or coiled
            if flags != UNW_FLAG_UHANDLER && flags != UNW_FLAG_EHANDLER && flags != 3
            {
                continue;
            }

            // Skip over the RVA to the handler, to the exception data
            let offset_of_exception_data = offset_of_handler_or_data + 4;
                
            let suspected_cxx_s_fun_info = ptr_unwind_info.offset(offset_of_exception_data);

            // Check if the exception data is a pointer to a cxx_s_fun_info struct that is supported by this checker,
            // filter out the new Cxx4 EH and older versions
            if !is_exception_data_cxx_s_fun_info(ptr_self as u64, suspected_cxx_s_fun_info)
            {
                continue;
            }

            // This is the struct we need to check
            let cxx_s_fun_info_rva = *(suspected_cxx_s_fun_info as *const u32);
            let cxx_s_fun_info_va = ptr_self as u64 + cxx_s_fun_info_rva as u64;
            println!("Info: suspected cxx_s_fun_info_va: 0x{:x}", cxx_s_fun_info_va);

            let cxx_s_fun_info: *const SFuncInfo = cxx_s_fun_info_va as *const _;

            let n_ip_map_entries = (*cxx_s_fun_info).n_ip_map_entries as usize;
            
            // The bug is in the table that maps IP to state, so if there are no entries, we can skip this function
            if n_ip_map_entries == 0
            {
                continue;
            }

            // All these fields are RVA's, so we need to add the base address to get the VA
            let ip_to_state_map_rva = (*cxx_s_fun_info).disp_ip_to_state_map;

            // The ip_to_state_map_rva is the RVA of the first entry, so we need to add the base address to get the VA
            let ip_to_state_map_va = ptr_self as u64 + ip_to_state_map_rva as u64;
            
            // Note: on my system, the compiler consistently puts the ip_to_state_map behind the cxx_s_fun_info struct, but this is not guaranteed I think
            println!("Info: ip_to_state_map_va: 0x{:x}", ip_to_state_map_va);

            for i in 0..n_ip_map_entries
            {
                let ip_to_state_map_entry_va = (ip_to_state_map_va as u64) + (i * std::mem::size_of::<SIptoStateMapEntry>()) as u64;

                let ip_to_state_map_entry: * const SIptoStateMapEntry = (ip_to_state_map_entry_va as *const _);

                println!("Info: ip_to_state_map_entry: 0x{:x}", (*ip_to_state_map_entry).ip);
                println!("Info: ip_to_state_map_entry: 0x{:x}", (*ip_to_state_map_entry).state);

                let check_address = ((ptr_self as u64) + (*ip_to_state_map_entry).ip as u64) as *const u8;

                println!("Info: check_address: CHECKMECHECKMECHECKME: 0x{:x}", check_address as u64);


                // Note that this is just a heuristic, it is possible that the compiler has put other values on your system here
                // So this check may not trigger, try a few of the "CHECKMECHECKMECHECKME" addresses in a debugger to see if they point to an instruction boundary
                if (*check_address) == 0x01 && (*check_address.sub(1)) == 0xba
                {
                    println!("Info: found suspected faulty entry: 0x{:x}", check_address as u64);
                    println!("Info: It should most likely point to 0x{:x}", (check_address.sub(1)) as u64);
                    

                    // Ask enter
                    let mut input = String::new();
                    std::io::stdin().read_line(&mut input).unwrap();

                }
            }
        }
       
    }

    println!("Hello, world!");
}

I use the following versions:

rustc --version
rustc 1.74.0-nightly (37390d656 2023-09-24)
cargo --version
cargo 1.74.0-nightly (414d9e3a6 2023-09-22)

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-LLVMArea: Code generation parts specific to LLVM. Both correctness bugs and optimization-related issues.A-codegenArea: Code generationA-panicArea: Panicking machineryC-bugCategory: This is a bug.I-unsoundIssue: A soundness hole (worst kind of bug), see: https://en.wikipedia.org/wiki/SoundnessO-windowsOperating system: WindowsO-x86_64Target: x86-64 processors (like x86_64-*) (also known as amd64 and x64)P-mediumMedium priority

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions