Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions tools/tests/test_unwrap_self.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
PT_DYNAMIC = 2
PT_TLS = 7
PT_NOTE = 4
PT_PHDR = 6
SCE_COMMENT = 0x6FFFFF00
SCE_VERSION = 0x6FFFFF01
FIRST_REGION = 0x400
Expand Down Expand Up @@ -849,6 +850,147 @@ def test_program_headers_do_not_overwrite_the_first_load_payload(self):
self.assertEqual(len(headers), count)
self.assertTrue(all(header[0] == 0 for header in headers[1:]))

def test_phdr_names_the_rewritten_table_in_the_load_image(self):
table_size = 2 * 56
for address in (0, 0x4000):
for delta in (0, 0x40):
for recorded in (False, True):
with self.subTest(address=address, delta=delta, recorded=recorded):
table_offset = PAGE + delta
segments = [(PT_PHDR, 4, table_offset, address + delta, table_size, 8),
(PT_LOAD, 4, PAGE, address, 0x400, PAGE)]
table = build_elf(segments, table_offset)[table_offset:table_offset + table_size]
payload = bytearray(b"load payload\0" * 80)[:0x400]
payload[delta:delta + table_size] = table
records = [(0x100800, 0x3000, len(payload))]
if recorded:
records.insert(0, (0x800, 0x3000 + delta, table_size))
container = build_container(segments, records, {0x3000: payload},
program_offset=table_offset)
result, output = self.convert(container)
self.assertEqual(result.returncode, 0, result.stderr)
data, headers = read_program_headers(output)
phdr, load = headers
rewritten = struct.unpack_from("<Q", data, 0x20)[0]
self.assertEqual(rewritten, phdr[2])
self.assertEqual(phdr[2], load[2] + delta)
self.assertEqual(phdr[3], load[3] + delta)
expected = b"".join(struct.pack("<IIQQQQQQ", *header) for header in headers)
self.assertEqual(data[phdr[2]:phdr[2] + table_size], expected)
self.assertEqual(data[load[2]:phdr[2]], payload[:delta])
self.assertEqual(data[phdr[2] + table_size:load[2] + load[5]], payload[delta + table_size:])
self.assertEqual((Path(self.directory) / "input.self").read_bytes(), container)

def test_invalid_phdr_metadata_preserves_input_and_output(self):
load = (PT_LOAD, 4, PAGE, 0x4000, 0x400, PAGE)
phdr = (PT_PHDR, 4, PAGE, 0x4000, 2 * 56, 8)
duplicate = (PT_PHDR, 4, PAGE, 0x4000, 3 * 56, 8)
cases = [([duplicate, duplicate, load], None, "more than one PT_PHDR segment"),
([load, phdr], None, "PT_PHDR must precede the load segments")]
cases += [([(PT_PHDR, 4, PAGE, 0x4000, size, 8), load], 2 * 56,
"PT_PHDR does not cover the program header table") for size in (0, 2 * 56 - 1)]
cases += [([phdr, load], size, "PT_PHDR does not cover the program header table")
for size in (0, 2 * 56 - 1)]
directory = Path(self.directory)
source, output = directory / "input.self", directory / "output.elf"
for segments, memory, message in cases:
container = bytearray(build_container(segments, [(0x100800, 0x3000, 0x400)],
{0x3000: (b"load data\0" * 128)[:0x400]}, program_offset=PAGE))
if memory is not None:
struct.pack_into("<Q", container, ELF_HEADER_OFFSET + PAGE + 40, memory)
table = ELF_HEADER_OFFSET + PAGE
container[0x3000:0x3000 + len(segments) * 56] = container[table:table + len(segments) * 56]
source.write_bytes(container)
for previous in (None, b"previous output"):
with self.subTest(segments=segments, memory=memory, previous=previous):
output.unlink(missing_ok=True)
if previous is not None:
output.write_bytes(previous)
before = set(directory.iterdir())
errors = io.StringIO()
with contextlib.redirect_stderr(errors), contextlib.redirect_stdout(io.StringIO()):
status = unwrap_self.main([str(source), str(output)])
self.assertEqual(status, 2)
self.assertIn(message, errors.getvalue())
self.assertEqual(source.read_bytes(), container)
self.assertEqual(output.read_bytes() if output.exists() else None, previous)
self.assertEqual(set(directory.iterdir()), before)

def test_phdr_mapping_other_load_bytes_preserves_input_and_output(self):
table_size = 2 * 56
cases = [(0x4000, 0, 0, PROGRAM_TABLE_OFFSET),
(0x4000, 0x400000, 0x40, PROGRAM_TABLE_OFFSET),
(0, 0, 0x1040, 0x1040)]
directory = Path(self.directory)
source, output = directory / "input.self", directory / "output.elf"
for load_offset, address, delta, phdr_offset in cases:
segments = [(PT_PHDR, 4, phdr_offset, address + delta, table_size, 8),
(PT_LOAD, 4, load_offset, address, 0x1400, PAGE)]
payload = (b"load bytes outside the program header table\0" * 128)[:0x1400]
for recorded in (False, True):
records = [(0x100800, 0x5000, len(payload))]
if recorded:
records.insert(0, (0x800, 0x5000 + delta, table_size))
container = build_container(segments, records, {0x5000: payload})
source.write_bytes(container)
for previous in (None, b"previous output"):
with self.subTest(segments=segments, recorded=recorded, previous=previous):
output.unlink(missing_ok=True)
if previous is not None:
output.write_bytes(previous)
before = set(directory.iterdir())
result, output = self.convert(container)
self.assertEqual(result.returncode, 2, result.stderr)
self.assertIn("PT_PHDR does not map the program header table", result.stderr)
self.assertNotIn("Traceback", result.stderr)
self.assertEqual(source.read_bytes(), container)
self.assertEqual(output.read_bytes() if output.exists() else None, previous)
self.assertEqual(set(directory.iterdir()), before)

def test_larger_phdr_after_a_non_load_header_preserves_surrounding_bytes(self):
table_size = 3 * 56
delta = 0x40
segments = [(PT_NOTE, 4, 0, 0, 0, 4),
(PT_PHDR, 4, PAGE + delta, 0x4000 + delta, table_size + 0x20, 8),
(PT_LOAD, 4, PAGE, 0x4000, 0x400, PAGE)]
table = build_elf(segments, PAGE + delta)[PAGE + delta:PAGE + delta + table_size]
payload = bytearray(b"load payload\0" * 80)[:0x400]
payload[delta:delta + table_size] = table
for recorded in (False, True):
with self.subTest(recorded=recorded):
records = [(0x200800, 0x3000, len(payload))]
if recorded:
records.insert(0, (0x100800, 0x3000 + delta, table_size + 0x20))
container = build_container(segments, records, {0x3000: payload}, program_offset=PAGE + delta)
result, output = self.convert(container)
self.assertEqual(result.returncode, 0, result.stderr)
data, headers = read_program_headers(output)
phdr, load = headers[1:]
self.assertEqual(struct.unpack_from("<Q", data, 0x20)[0], phdr[2])
self.assertEqual(phdr[2], load[2] + delta)
self.assertEqual((phdr[5], phdr[6]), (table_size + 0x20, table_size + 0x20))
expected = b"".join(struct.pack("<IIQQQQQQ", *header) for header in headers)
self.assertEqual(data[phdr[2]:phdr[2] + table_size], expected)
self.assertEqual(data[load[2]:phdr[2]], payload[:delta])
self.assertEqual(data[phdr[2] + table_size:load[2] + load[5]], payload[delta + table_size:])
self.assertEqual((Path(self.directory) / "input.self").read_bytes(), container)

def test_phdr_record_outside_its_load_image_is_refused(self):
segments = [(PT_PHDR, 4, PAGE, 0x4000, 2 * 56, 8),
(PT_LOAD, 4, PAGE, 0x4000, 0x400, PAGE)]
container = build_container(segments, [(0x800, 0x4000, 2 * 56), (0x100800, 0x3000, 0x400)],
{0x3000: (b"load data\0" * 128)[:0x400], 0x4000: b"x" * (2 * 56)},
program_offset=PAGE)
directory = Path(self.directory)
source, output = directory / "input.self", directory / "output.elf"
source.write_bytes(container)
output.write_bytes(b"previous output")
with self.assertRaisesRegex(unwrap_self.Refused, "PT_PHDR is not in a file-backed load segment"):
unwrap_self.unwrap(source, output)
self.assertEqual(source.read_bytes(), container)
self.assertEqual(output.read_bytes(), b"previous output")
self.assertEqual(set(directory.iterdir()), {source, output})

def test_the_verbose_report_names_every_program_header(self):
container = build_container(default_segments(), default_records(), default_payloads())
directory = Path(self.directory)
Expand Down
42 changes: 32 additions & 10 deletions tools/unwrap_self.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,18 @@
bytes holding flags, data offset, size and the size again. A record with flag
0x800 holds the data of the program header whose index is in flags bits 20
and up. Segments the table does not cover are placed by the vaddr to file
mapping of the PT_LOAD that contains them. Segments without a vaddr that
follow the last recorded one in the ELF are stored right after it, back to
back in ELF order, except a PT_NOTE, which the container leaves out and which
is emitted without file bytes. Some dumps end right after the recorded ones
and leave out the version segment as well, so one that would run past the
end of the file is emitted without file bytes too. Other segments without a
vaddr carry the shift of the last placed one.
mapping of the PT_LOAD that contains them, including a PT_PHDR at vaddr zero.
Other segments without a vaddr that follow the last recorded one in the ELF
are stored right after it, back to back in ELF order, except a PT_NOTE, which
the container leaves out and which is emitted without file bytes. Some dumps
end right after the recorded ones and leave out the version segment as well,
so one that would run past the end of the file is emitted without file bytes
too. Other segments without a vaddr carry the shift of the last placed one.
- The segments are packed without honouring p_align, while ELF requires
p_offset % p_align == p_vaddr % p_align. Every region is copied to an offset
that satisfies it, which the relinker enforces on guest modules.
that satisfies it, which the relinker enforces on guest modules. The ELF
header points to the rewritten table at the mapped PT_PHDR offset, when
present. A PT_PHDR mapping different bytes from the input table is refused.

Only decrypted containers are handled. A container whose load segments all look
encrypted is refused instead of copied: the entropy of each load segment is
Expand Down Expand Up @@ -50,6 +52,7 @@
MIN_OUTPUT_LIMIT = 256 * 1024 * 1024
PT_LOAD = 1
PT_NOTE = 4
PT_PHDR = 6
ENTROPY_LIMIT = 7.5
ENTROPY_SAMPLE = 0x10000

Expand All @@ -58,7 +61,7 @@
2: "PT_DYNAMIC",
3: "PT_INTERP",
4: "PT_NOTE",
6: "PT_PHDR",
PT_PHDR: "PT_PHDR",
7: "PT_TLS",
0x6474E550: "PT_GNU_EH_FRAME",
0x6474E551: "PT_GNU_STACK",
Expand Down Expand Up @@ -206,7 +209,7 @@ def load_for(vaddr, size):
if header["size"] and (tail is None or header["offset"] > tail[0]):
tail = (header["offset"], header["source"] - header["offset"])
continue
if header["vaddr"] != 0:
if header["vaddr"] != 0 or header["type"] == PT_PHDR:
host = load_for(header["vaddr"], header["size"])
if host is None:
if header["size"] == 0:
Expand Down Expand Up @@ -266,7 +269,23 @@ def unwrap(path, output, verbose=False):

records = read_records(data)
inner, elf_header, headers = parse_elf(data)
phdrs = [header for header in headers if header["type"] == PT_PHDR]
if len(phdrs) > 1:
raise Refused("more than one PT_PHDR segment")
phdr = phdrs[0] if phdrs else None
if phdr is not None:
if phdr["size"] < len(headers) * 56 or phdr["memory"] < len(headers) * 56:
raise Refused("PT_PHDR does not cover the program header table")
if any(header["type"] == PT_LOAD and header["index"] < phdr["index"] for header in headers):
raise Refused("PT_PHDR must precede the load segments")
place_segments(data, headers, records)
if phdr is not None:
if phdr["host"] is None:
raise Refused("PT_PHDR is not in a file-backed load segment")
table = inner + struct.unpack_from("<Q", elf_header, 0x20)[0]
table_size = len(headers) * 56
if data[phdr["source"]:phdr["source"] + table_size] != data[table:table + table_size]:
raise Refused("PT_PHDR does not map the program header table")
check_decrypted(data, headers)

regions = []
Expand Down Expand Up @@ -350,6 +369,9 @@ def output_region(header):
if header["new"] + header["size"] > len(result):
raise Refused("segment %d escaped the output" % header["index"])

if phdr is not None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should fix: this points e_phoff at phdr["new"] and writes the rewritten table there, but nothing checks that the PT_PHDR maps the program header table. Its source comes only from p_vaddr - host.vaddr (lines 199-214), or from its SELF record position (lines 189-197), and line 269 only requires a host load. If p_offset and p_vaddr disagree with the load, or e_phoff != p_offset, the tool exits 0 and overwrites len(headers) * 56 bytes of load data. Three hand-built containers:

  • PT_PHDR offset 0x40, vaddr 0; PT_LOAD offset 0x4000, vaddr 0: load bytes change at +0x0.
  • PT_PHDR offset 0x40, vaddr 0x400040; PT_LOAD offset 0x4000, vaddr 0x400000: load bytes change at +0x40.
  • e_phoff 0x40, outside the loads; PT_PHDR offset and vaddr 0x1040 inside a load at offset 0, vaddr 0: load bytes change at +0x1040.

main leaves those bytes intact, and its stale PT_PHDR entry does no harm because the relinker drops input PT_PHDR (core/relinker/elfpatcher/src/general/SegmentFilter.cpp:19). So this is a regression, limited to PT_PHDR metadata that breaks the gABI, and it contradicts "Bytes outside the table and the input container are preserved". Refusing when the mapped bytes are not the table closes it, right after line 270:

    if phdr is not None:
        table = inner + struct.unpack_from("<Q", elf_header, 0x20)[0]
        if data[phdr["source"]:phdr["source"] + len(headers) * 56] != data[table:table + len(headers) * 56]:
            raise Refused("PT_PHDR does not map the program header table")

With it, your three PT_PHDR tests pass, the three containers above are refused, and the 3,000 random valid PT_PHDR containers and 3,000 without PT_PHDR give the same output as before with no refusal. A test with a PT_PHDR whose load bytes are not the table would pin it.

program_offset = phdr["new"]
struct.pack_into("<Q", elf_header, 0x20, program_offset)
result[0:0x40] = elf_header
struct.pack_into("<Q", result, 0x28, 0)
struct.pack_into("<H", result, 0x3C, 0)
Expand Down
Loading