Adding a segment to an existing macOS Mach-O binary

A common technique for adding data to an existing Mach-O is to simply append the data to the end of the binary. This is the classic self-extracting archive trick, and it’s used by various tools for compiling/bundling scripting languages along with their interpreter into a single executable file. While this trick works, there is a major limitation: You cannot codesign the binary.

1
2
3
4
$ clang -o helloworld helloworld.c
$ echo 'appended' >> helloworld
$ sudo codesign -fs - helloworld
helloworld: main executable failed strict validation

Now you might be thinking: “What if I codesign it first, then append the data?” As you might expect, the signature is invalidated once data is appended (obviously it would be bad if two different files could have the same signature).

1
2
3
4
5
$ clang -o helloworld helloworld.c
$ sudo codesign -fs - helloworld
$ echo 'appended' >> helloworld
$ codesign -vv helloworld
helloworld: main executable failed strict validation

The exact reason having data appended to a binary is not strictly valid is somewhat obscure, but essentially the __LINKEDIT segment data must be at the end of the file. This is also the segment which will be modified to include the code signature data directly.

So if we can’t appended more arbitrary data after the __LINKEDIT segment data, where can we put it so that it’s strictly valid? Well if you read the title, you probably already know the answer: In a new segment.

Getting Started

To get started on this project, let’s make some sample code to test with. In my case, I’ll just make some C code that gets a pointer to the Mach-O header in memory, and simply finds my __CUSTOM,__custom segment section and writes the data to stdout. You could also read the Mach-O binary from the disk, but this is more-efficient since the segment will already be mapped into memory. I should note that this code uses the 64-bit structures only (you could add 32-bit support, but 32-bit code on macOS is obsolete at this point anyhow) and was only testing on an Intel binary (though I expect it would work fine with an Apple Silicon binary too).

main.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#include <stdio.h>
#include <string.h>
#include <mach-o/dyld.h>

struct segment_command_64 * find_segment(
const struct mach_header * header,
const char * segname
) {
struct load_command * command =
(void *)(((uint8_t *)header) + sizeof(struct mach_header_64));
for (uint32_t i = 0; i < header->ncmds; i++) {
if (command->cmd == LC_SEGMENT_64) {
struct segment_command_64 * segment = (void *)command;
if (!strncmp(segment->segname, segname, 16)) {
return segment;
}
}
command = (void *)(((uint8_t *)command) + command->cmdsize);
}
return NULL;
}

struct section_64 * find_section(
struct segment_command_64 * command,
const char * sectname
) {
struct section_64 * section =
(void *)(((uint8_t *)command) + sizeof(struct segment_command_64));
for (uint32_t i = 0; i < command->nsects; i++) {
if (!strncmp(section->sectname, sectname, 16)) {
return section;
}
section++;
}
return NULL;
}

void * find_segment_section_body(
const struct mach_header * header,
const char * segname,
const char * sectname,
size_t * size
) {
struct segment_command_64 * segment = find_segment(header, segname);
if (!segment) {
return NULL;
}
struct section_64 * section = find_section(segment, sectname);
if (!section) {
return NULL;
}
*size = section->size;

// Use header + offset to include ASLR offset (not addr).
return (void *)(((uint8_t *)header) + section->offset);
}

int main() {
size_t size = 0;
void * body = find_segment_section_body(
_dyld_get_image_header(0),
"__CUSTOM",
"__custom",
&size
);
if (body && size) {
fwrite(body, 1, size, stdout);
}
return 0;
}

Now if we compile and run our binary we see nothing is output since we haven’t added our custom segment yet.

1
2
$ clang -o main main.c
$ ./main

In order to test our code before we get to manually modifying our binary, we can have clang link in a new segment of seemingly unused data for us to work with.

data.c
1
2
3
__attribute__((section("__CUSTOM,__custom")))

static const char message[] __attribute__((used)) = "Hello, World!";
1
2
3
4
5
$ clang -o main-data main.c data.c
$ ./main-data
Hello, World!%
$ ./main-data | xxd
00000000: 4865 6c6c 6f2c 2057 6f72 6c64 2100 Hello, World!.

That string we forced into our custom segment gets printed to stdout (including the null byte, naturally, since it’s a C-string).

Alright, so now we know our C code can work, if we can just insert a new segment after compiling and linking the binary.

Modifying Existing Binaries

Now for the tricky part, what do we need to do to insert a new segment into a Mach-O? Again the knowledge is somewhat obscure, but knowing __LINKEDIT data must be last and reading through the loader.h kernel header and comparing it to some real Mach-O examples makes it clear.

  1. The __LINKEDIT segment data must be at the end of the file.
    • Actually, dyld will reject any binary with segment data after __LINKEDIT.
  2. We need to add a new segment_command_64 into the header.
    • Such a command would normally be inserted before the __LINKEDIT command by clang, though this is not currently a hard requirement (we will do what clang does below anyway).
  3. We will have to shift the __LINKEDIT segment data down to make room for our new segment data, and also shift the offsets and addresses in the command.
  4. There are a few other load commands which can reference data within the __LINKEDIT segment, which might also need shifting.
    • dyld_info_command
    • symtab_command
    • dysymtab_command
    • linkedit_data_command

Alright, onto the code. For simplicity I will use Python with the macholib module for my proof-of-concept. As there’s only a handful of structure you need to parse, this could be done in other languages fairly easily without the need for full Mach-O parsing. I also skipped FAT binary support, though it wouldn’t be too hard to support that too.

appendsection.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
#!/usr/bin/env python3

import io
import os
import sys
import contextlib
from macholib.ptypes import (
sizeof
)
from macholib.mach_o import (
LC_SEGMENT_64,
load_command,
segment_command_64,
section_64,
dyld_info_command,
symtab_command,
dysymtab_command,
linkedit_data_command
)
from macholib.MachO import (
MachO
)

VM_PROT_NONE = 0x00
VM_PROT_READ = 0x01
VM_PROT_WRITE = 0x02
VM_PROT_EXECUTE = 0x04

SEG_LINKEDIT = b'__LINKEDIT'

def align(size, base):
over = size % base
if over:
return size + (base - over)
return size

def copy_io(src, dst, size=None):
blocksize = 2 ** 23
if size is None:
while True:
d = src.read(blocksize)
if not d:
break
dst.write(d)
else:
while size:
s = min(blocksize, size)
d = src.read(s)
if len(d) != s:
raise Exception('Read error')
dst.write(d)
size -= s

def vmsize_align(size):
return align(max(size, 0x4000), 0x1000)

def cstr_fill(data, size):
if len(data) > size:
raise Exception('Pad error')
return data.ljust(size, b'\x00')

def find_linkedit(commands):
for i, cmd in enumerate(commands):
if not isinstance(cmd[1], segment_command_64):
continue
if cmd[1].segname.split(b'\x00')[0] == SEG_LINKEDIT:
return (i, cmd)

def shift_within(value, amount, within):
if value < within[0] or value > (within[0] + within[1]):
return value
return value + amount

def shift_commands(commands, amount, within, shifts):
for (Command, props) in shifts:
for (_, cmd, _) in commands:
if not isinstance(cmd, Command):
continue
for p in props:
v = getattr(cmd, p)
setattr(cmd, p, shift_within(v, amount, within))

def main(args):
if len(args) <= 5:
print('Usage: macho_in macho_out segname sectname sectfile')
return 1
(_, macho_in, macho_out, segname, sectname, sectfile) = args

with contextlib.ExitStack() as stack:
fi = stack.enter_context(open(macho_in, 'rb'))
fo = stack.enter_context(open(macho_out, 'wb'))
fs = stack.enter_context(open(sectfile, 'rb'))

macho = MachO(macho_in)
if macho.fat:
raise Exception('FAT unsupported')
header = macho.headers[0]

# Find the closing segment.
(linkedit_i, linkedit) = find_linkedit(header.commands)
(_, linkedit_cmd, _) = linkedit

# Remember where closing segment data is.
linkedit_fileoff = linkedit_cmd.fileoff

# Find the size of the new segment content.
fs.seek(0, io.SEEK_END)
sect_size = fs.tell()
fs.seek(0)

# Create the new segment with section.
lc = load_command(_endian_=header.endian)
seg = segment_command_64(_endian_=header.endian)
sect = section_64(_endian_=header.endian)
lc.cmd = LC_SEGMENT_64
lc.cmdsize = sizeof(lc) + sizeof(seg) + sizeof(sect)
seg.segname = cstr_fill(segname.encode('ascii'), 16)
seg.vmaddr = linkedit_cmd.vmaddr
seg.vmsize = vmsize_align(sect_size)
seg.fileoff = linkedit_cmd.fileoff
seg.filesize = seg.vmsize
seg.maxprot = VM_PROT_READ
seg.initprot = seg.maxprot
seg.nsects = 1
sect.sectname = cstr_fill(sectname.encode('ascii'), 16)
sect.segname = seg.segname
sect.addr = seg.vmaddr
sect.size = sect_size
sect.offset = seg.fileoff

# Shift closing segment down.
linkedit_cmd.vmaddr += seg.vmsize
linkedit_cmd.fileoff += seg.filesize

# Shift any offsets that could reference that segment.
shift_commands(
header.commands,
seg.filesize,
(linkedit_fileoff, linkedit_cmd.filesize),
[
(dyld_info_command, [
'rebase_off',
'bind_off',
'weak_bind_off',
'lazy_bind_off',
'export_off'
]),
(symtab_command, [
'symoff',
'stroff'
]),
(dysymtab_command, [
'tocoff',
'modtaboff',
'extrefsymoff',
'indirectsymoff',
'extreloff',
'locreloff'
]),
(linkedit_data_command, [
'dataoff'
])
]
)

# Update header and insert the segment.
header.header.ncmds += 1
header.header.sizeofcmds += lc.cmdsize
header.commands.insert(linkedit_i, (lc, seg, [sect]))

# Write the new header.
header.write(fo)

# Copy the unchanged data.
fi.seek(fo.tell())
copy_io(fi, fo, linkedit_fileoff - fo.tell())

# Write new section data, padded to segment size.
copy_io(fs, fo, sect_size)
fo.write(b'\x00' * (seg.filesize - sect_size))

# Copy remaining unchanged data.
copy_io(fi, fo)

# Copy mode to the new file.
os.chmod(macho_out, os.stat(macho_in).st_mode)

return 0

if __name__ == '__main__':
sys.exit(main(sys.argv))

I also made a simple text file, to hold the data I’m going to append (in my case without the trailing newline character).

sectfile.txt
1
Hello, World!

Now we just have to run it like so to make a modified copy of our main binary from above and see it in action:

1
2
3
4
5
$ ./appendsection.py main main-appended __CUSTOM __custom sectfile.txt
$ ./main-appended
Hello, World!%
$ ./main-appended | xxd
00000000: 4865 6c6c 6f2c 2057 6f72 6c64 21 Hello, World!

Nice, our segment data was printed exactly as we created it in the file!

Now for the real moment of truth, can we codesign it?

1
2
3
4
5
6
$ sudo codesign -fs - main-appended
$ codesign -vv main-appended
main-appended: valid on disk
main-appended: satisfies its Designated Requirement
$ ./main-appended
Hello, World!%

Yep, singing worked perfectly!

Comments