EXOTIC SILICON
“Assembler code broken by pinsyscalls()? Let's fix it!”
Creating a pintable manually for assembly language programming
Assembler programming on OpenBSD?
We'll need a pintable for that...
Introduction
Ah, assembler programming on OpenBSD... Freedom from the C libraries, just our code and the CPU...
Until, that is, that the code in question crashes with this:
a.out[32643]: pinsyscalls addr 2011d8 code 4, pinoff 0xffffffff (pin 0 0-0 0) (libcpin 0 0-0 0) error 78
Abort trap
Followers of the system's development process will have known for some time that syscall pinning was coming. That was generally accepted as a good thing, and for C programmers, it's pretty much all handled automatically by the linker and the kernel, (which in itself is usually a benefit for a security feature, so great!).
But for those of us who code in assembler, the obvious question was how this mechanism could be set up by code that doesn't even use the C standard library.
Luckily, there are ways to do exactly this, and whilst seeing existing code suddenly crash might appear daunting, actually implementing a pintable in your assembler code is fairly trivial. Not to mention that we reap the benefits of syscall pinning as well, we're not simply disabling it.
Well actually, disabling it at least partially is indeed a possibility, but we'll look at better ways as well.
History
OpenBSD 7.6-release was the first release where asm programs required a pintable, OpenBSD 7.5-release didn't require it.
In fact, up until OpenBSD 7.5-release, hand-written assembly code didn't really require anything special at all.
Sample code
Pure assembly on OpenBSD usually looks, at a minimum, something like this:
.section ".note", "a"
.p2align 2
.long 0x08
.long 0x04
.long 0x01
.asciz "OpenBSD"
.long 0x00
.p2align 2
.section .text
.globl _start
_start:
jmp _start
We're using AMD64 assembly in the examples in this article, but the general principles of syscall pinning are basically machine independent and it works the same way across all supported architectures.
To assemble this in to an ELF executable, we can do the following:
$ as -o filename.o filename.S
$ ld -o filename --Bstatic --no-pie filename.o
Creating an ELF executable from our assembler source code.
Top tip!
Shell function to run assembly programs
The following ksh function can be used to assemble, link, and run an assembler source code file with a single command:
function asm_run { [ -e "$1" ] && filename_out_temp="${1##*/}" && filename_out="${filename_out_temp%.S}" && as -o "$filename_out".o $1 && ld -o $filename_out -Bstatic --no-pie "$filename_out".o && ./$filename_out ; }
Add it to your shell startup profile, and then invoke it with the filename of the assembler source, (which is assumed to end with .S).
For example:
$ asm_run my_program.S
The object file and executable will be written to the current directory, then the executable will be run.
The example program shown above simply enters an endless loop, and since it doesn't make any syscalls it will indeed assemble and run just fine on recent versions of OpenBSD which enforce pinsyscalls().
This otherwise pointless example demonstrates the concept that pinsyscalls() doesn't in itself prevent hand-written assembler code from running. As long as we follow the new requirements, there is no obligation to use the C standard library.
On the other hand, without the C libraries we can't do much useful computation without making a syscall. If you're thinking that some limited applications might be possible, realise that even calling SYS_EXIT will fail:
.section ".note", "a"
.p2align 2
.long 0x08
.long 0x04
.long 0x01
.asciz "OpenBSD"
.long 0x00
.p2align 2
.section .text
.globl _start
_start:
mov $0x01, %rax
syscall
a.out[32643]: pinsyscalls addr 2011af code 1, pinoff 0xffffffff (pin 0 0-0 0) (libcpin 0 0-0 0) error 78
Abort trap
The solution here is to include a valid pintable in our source code.
Pintable format
The format of the pintable is very simple, it's just pairs of unsigned 32-bit values indicating the permitted address offset, and the system call number.
.long address_offset
.long syscall_number
Obviously the assembler can calculate the address offsets for us, so in practice this value will usually be a source code label.
For example, if we use the write syscall only once in our code, and label it with the label _write_syscall then we could add the following entry to the pintable to permit it:
.long (_write_syscall)
.long 0x04
The raw pintable data is placed in an ELF section called ‘.openbsd.syscalls’.
To see how this works in practice, here is a simple hello world program that makes two syscall calls, one to syswrite and one to sysexit.
.section ".note", "a"
.p2align 2
.long 0x08
.long 0x04
.long 0x01
.asciz "OpenBSD"
.long 0x00
.p2align 2
.section ".openbsd.syscalls"
.long (_exit_syscall)
.long 0x01
.long (_write_syscall)
.long 0x04
.section .rodata
message:
.ascii "Hello world!"
.byte 0x0a
.section .text
.globl _start
_start:
mov $0x04, %rax
mov $0x01, %rdi
mov $message, %rsi
mov $0x0d, %rdx
_write_syscall:
syscall
mov $0x01, %rax
_exit_syscall:
syscall
This is simple enough, as long as your program only calls each syscall from one specific place.
Practical examples
Obviously real-world code usually needs to call at least some syscalls repeatedly, and from different code paths.
Since the whole idea of pinsyscalls() is to permit the syscall opcode to be executed only from the absolutely required code addresses, it wouldn't make much sense to permit an arbitrary number of addresses for this purpose. If it's going to be effective, it should allow just one single authorised address, (per syscall number and per program), which is indeed the usual mode of operation.
To make this work, we can make create a subroutine to actually perform the syscall opcode, and then call that subroutine as often as we like. If we take the previous example and add a second call to _sys_write_, it might look like this:
.section ".note", "a"
.p2align 2
.long 0x08
.long 0x04
.long 0x01
.asciz "OpenBSD"
.long 0x00
.p2align 2
.section ".openbsd.syscalls"
.long (_exit_syscall)
.long 0x01
.long (_write_syscall)
.long 0x04
.section .rodata
message:
.ascii "Hello world!"
.byte 0x0a
.section .text
.globl _start
_start:
mov $0x01, %rdi
mov $message, %rsi
mov $0x0d, %rdx
call _do_write
call _do_write
mov $0x01, %rax
_exit_syscall:
syscall
_do_write:
mov $0x04, %rax
_write_syscall:
syscall
ret
The only place that syscall is executed with rax=4 is at _do_write. This satisfies the pintable checking code in the kernel, and our program runs just fine.
Backwards compatibility
Note that assembler source code including a pintable in .openbsd.syscalls can be assembled, linked, and run without modification on older OpenBSD systems, as the extra ELF section will just be ignored.
Kernel code references
The pintable loading code is in elf_read_pintable() in sys/kern/exec_elf.c
Here we can see that pretty much anything that doesn't strictly meet the criteria will be rejected with a goto bad and npins set to zero. Overly large .openbsd.syscalls sections are rejected, and a single entry with an index for a syscall beyond the highest present in the kernel, (SYS_MAXSYSCALL), will cause the entire pintable to be rejected.
At this point reading the kernel code, it's interesting to note that a duplicate entry for the same syscall number causes the parser to store the magic value -1 as the address offset which then allows that specific syscall to be called from anywhere, (just like the ‘old days’).
Knowing this not only makes the * 2 multiplier in the size checking code above more useful, but also shows us a way to avoid strict syscall pinning in our assembly coding if we wanted to.
Of course, the dynamic linker has to parse pintables in a similar way, and that code can be found in src/libexec/ld.so/resolve.c.
Back in the kernel, the actual checking of the pintable at runtime when a syscall opcode is encountered is done in pin_check() in sys/sys/syscall_mi.h.
Using duplicated entries to whitelist specific syscalls
As mentioned above, a second entry for the same syscall number has the effect of liberating the use of that syscall from any address.
The address offset value doesn't even have to be in the .text section either.
In this example, we've used the address of the ASCII 'Hello world!' string as the pintable value for sys_write. The parser happily accepts this, sees a second entry for sys_write, and sets the magic flag that disables checking of the address in pin_check().
.section ".note", "a"
.p2align 2
.long 0x08
.long 0x04
.long 0x01
.asciz "OpenBSD"
.long 0x00
.p2align 2
.section ".openbsd.syscalls"
.long (_exit_syscall)
.long 0x01
.long (message)
.long 0x04
.long (message)
.long 0x04
.section .rodata
message:
.ascii "Hello world!"
.byte 0x0a
.section .text
.globl _start
_start:
mov $0x01, %rdi
mov $message, %rsi
mov $0x0d, %rdx
mov $0x04, %rax
syscall
mov $0x04, %rax
syscall
mov $0x01, %rax
_exit_syscall:
syscall
In the code above, sys_write is whitelisted and can be called from any address.
The two duplicated address values don't even need to be the same, we could just as easily have used a different value:
.long (message)
.long 0x04
.long 0xFF
.long 0x04
Using two different addresses also works.
Note that this creates a potential class of bugs when writing pintables by hand.
If a syscall number is unintentionally duplicated, then no errors will be thrown either during the assembly and linking, or at runtime. However, the syscall will remain callable from any location for the duration of the program.
Summary
Today we've seen how to add a pintable to an assembly language program, allowing it to assemble and run on versions of OpenBSD which have the pinsyscalls() functionality enabled.
We've looked at the format of the pintable itself and seen two ways to permit specific syscalls, either from a specific address, or alternatively from any address in the program.