angr / angr/cle

PPC64 ABIv1 JMP_SLOT relocations read a function descriptor out of an 8-byte extern slot

Open
#747 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Python
Stars
485
Forks
135
Avg merge
2d 2h
Merged PRs (30d)
15

Description

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

## Summary

On a PPC64 ELFv1 object, `R_PPC64_JMP_SLOT.relocate()` copies a 24-byte function
descriptor out of the memory of the symbol it resolved to. When the symbol resolves into
the extern object, a descriptor is there only if `ExternObject.make_extern()` built one,
and it builds one only when the symbol type is exactly `SymbolType.TYPE_FUNCTION`. An
undefined symbol typed `STT_NOTYPE` — what an import looks like when its producer did not
annotate it — gets a plain 8-byte slot instead, and the relocation reads 16 bytes past the
end of it.

That has two effects:

1. **Silently wrong.** The read lands on the following extern slots, which are zero, so
the PLT entry for the import is filled with entry point 0, TOC 0, environment 0. This
happens on every ELFv1 object that has an `STT_NOTYPE` import with a `JMP_SLOT`,
including the ones that load without an error.
2. **Fatal.** When the slot is the last one in the extern object, the read runs off the end
of the backer, `Clemory.unpack` raises `KeyError`, and `cle.Loader(...)` fails outright.

## Where

`cle/backends/elf/relocation/ppc64.py`, `R_PPC64_JMP_SLOT.relocate`:

```python
if self.owner.is_ppc64_abiv1:
# copy an entire function descriptor struct
addr = self.resolvedby.owner.memory.unpack_word(self.resolvedby.relative_addr)
toc = self.resolvedby.owner.memory.unpack_word(self.resolvedby.relative_addr + 8)
aux = self.resolvedby.owner.memory.unpack_word(self.resolvedby.relative_addr + 16)
```

`cle/backends/externs/__init__.py`, `ExternObject.make_extern`:

```python
make_toc = getattr(self.loader.main_object, "is_ppc64_abiv1", False) and sym_type == SymbolType.TYPE_FUNCTION
```

`sym_type` comes from the ELF symbol's `st_info.type`, so `STT_NOTYPE` reaches this as
`SymbolType.TYPE_NONE` and takes the `real_size = 8` path below instead.

## Evidence

Reproduced on cle master `45c6509`.

The object is a PPC64 big-endian `ET_DYN` ELF with `e_flags & 3 == 1` (ELFv1), a CPython
extension module, sha256
`0ac0c3e3e785b13a9d9a39f75a5588d2e873d34c0c6a979b28ebf5f78bb929e8`. Its `.dynsym` holds
193 undefined `STT_NOTYPE` symbols and 10 `STT_FUNC` ones.

```
>>> cle.Loader(path, auto_load_libs=False)
File "cle/loader.py", line 950, in _internal_load
obj.relocate()
File "cle/backends/backend.py", line 428, in relocate
reloc.relocate()
File "cle/backends/elf/relocation/ppc64.py", line 36, in relocate
aux = self.resolvedby.owner.memory.unpack_word(self.resolvedby.relative_addr + 16)
File "cle/memory.py", line 99, in unpack_word
return self.unpack(addr, self._arch.struct_fmt(size=size, signed=signed, endness=endness))[0]
File "cle/memory.py", line 55, in unpack
raise KeyError(addr)
KeyError: 1800
```

`0x708` is 1800, and the extern object's backer is exactly `0x708` bytes long, so the third
word of the "descriptor" for the symbol at `0x6f8` is the first byte past it.

Logging what each extern-resolved `JMP_SLOT` reads shows the silent case and the fatal one
in the same load. The slots are 8 bytes apart, so every descriptor is read out of the
neighbouring slots:

```
extern JMP_SLOT 'PyUnicode_FromFormat' slot=0x150 descriptor read = ['0x0', '0x0', '0x0']
extern JMP_SLOT 'PyObject_SetItem' slot=0x158 descriptor read = ['0x0', '0x0', '0x0']
extern JMP_SLOT 'PyList_New' slot=0x160 descriptor read = ['0x0', '0x0', '0x0']
...
extern JMP_SLOT 'PyObject_GC_UnTrack' slot=0x6f8 KeyError 1800
```

For contrast, `binaries/tests/ppc64/fauxware`, whose undefined imports are `STT_FUNC`, gets
the descriptor pair and 0x20-byte spacing, and its first descriptor word is populated:

```
extern JMP_SLOT '__libc_start_main' slot=0x18 descriptor read = ['0x10100018', '0x0', '0x0']
extern JMP_SLOT 'printf' slot=0x38 descriptor read = ['0x10100038', '0x0', '0x0']
```

## Scale

199 units of a corpus-wide CFG sweep fail on this signature, all of them ppc64 ELF. The
silent case is not in that count and is the larger one: it applies to every ELFv1 object
with an unannotated import, whether or not the load reaches the end of the extern object.

## Reproducing without that object

Any PPC64 ELFv1 shared object whose imports are `STT_NOTYPE` and reached through the PLT
does it. Undefined symbols referenced from assembly with no `.type name,@function`
directive are emitted that way:

```asm
.abiversion 1
.section ".opd","aw"
.globl f
f: .quad .Lf, .TOC.@tocbase, 0
.text
.Lf:
mflr 0
bl ext_a
bl ext_b
blr
```

```shell
powerpc64-linux-gnu-gcc -shared -o libx.so x.s
python -c "import cle; cle.Loader('libx.so', auto_load_libs=False)"
```

The fatal variant additionally needs the affected symbol to be the last one allocated in
the extern object; the silent variant needs nothing else.

## Options

This looks like a maintainer's call, so no pull request is attached:

1. Widen the `make_toc` condition so an ABIv1 extern gets the descriptor pair for
`TYPE_NONE` as well as `TYPE_FUNCTION`. It fixes both effects at the source, but it
changes extern object layout for every ABIv1 binary with unannotated imports, and
`TYPE_NONE` also covers imports that really are data.
2. Have `R_PPC64_JMP_SLOT.relocate()` detect a target with no descriptor — the resolved
symbol is shorter than 0x18, or its owner is the extern object — and write the resolved
address the way the non-ABIv1 branch does. Narrower, but it makes an extern PLT entry a
different shape from a real one.
3. Keep the descriptor copy but bound it to the resolved symbol's size, which stops the
`KeyError` and leaves the zeros.

Whether an extern ABIv1 import should be modelled as a real function descriptor at all is
the question underneath all three.

Contributor guide

No contributing guide indexed for this repository

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.