Stack Protectors in Swift

Stack protectors (also referred to as “stack canaries”) are additional code which is inserted to detect buffer overflows on the stack frame which can potentially overwrite the return address.
The clang compiler already has this feature for C/C++ code. We'd like to add this security feature also in the Swift compiler.
Swift is a safe language. So why do we need stack protectors in Swift? For two reasons:

  • In case unsafe operations (e.g. UnsafeMutablePointer) are used on stack allocated variables
func foo() {
  var x = 0
  withUnsafeMutablePointer(to: &x) {
    $0[1] = 0xdeadbeef
  }
}
  • If C/C++/ObjectiveC functions are called from Swift with pointers to stack allocated variables. Even if the buffer overflow happens on the C/C++/ObjectiveC side, clang’s stack protection doesn’t help here because the stack allocation is done on the Swift side.
func foo() {
  var x = 0
  withUnsafeMutablePointer(to: &x) {
    _ = memset($0, 0, 10)
  }
}

Stack allocation happens in swift in the following cases:

  • for large or generic variables in local scopes
  • contexts of non-escaping closures are allocated on the stack
  • class allocation can be stack promoted (including Array buffers).

According to some initial experiments, turning on stack protectors unconditionally for all Swift functions (which do stack allocations), results in significant code size increases. This is bad.

So the question is how can we detect the cases listed above and only insert stack protectors in functions where a potential overflow can occur?

Function-Local Analysis

The compiler can detect if an address of a stack allocated variable is in danger of causing an overflow within the scope of a function. Technically, it’s a simple analysis which checks all uses of alloc_stack, alloc_ref [stack] or partial_apply [on_stack] instructions and triggers if there is e.g. an address_to_pointer instruction in the use list.
Functions which contain such SIL patterns are marked as needing a stack protector.

Inter-procedural Analysis

It gets much more complicated if the stack allocation and the potential overflow are in different functions. This can happen if a stack address is passed to other functions, e.g. via an inoutparameter. It would still be too expensive to do a conservative default approach and insert stack protectors for all functions which pass stack addresses to other functions.

We can do two things to solve this problem:

If all call paths from a function with a stack allocation to functions with potential overflows are statically known, the compiler can do an inter-procedural analysis and insert stack protectors accordingly.

func foo() {
  var x = 0                   // allocation is done here
  bar(&x)
}

func bar(_ x: inout Int) {    // only called from foo()
  withUnsafeMutablePointer(to: &x) {
    $0[1] = 0xdeadbeef        // overflow happens here
  }
}

Sometimes it’s not the case that a call path is statically known. For example if a function call is dynamically dispatched, e.g. a vtable or witness table call. Or if a function is public and can be called from other modules.
But in this case it’s still possible to let such functions handle stack protection locally: the value which is passed via inout can be moved to a function-local stack area and then the stack protection logic can be done in such a function instead of its caller.

public func bar(_ x: inout Int) { // callers are unknown
  var tmp = x                     // move, inserted by the compiler
  withUnsafeMutablePointer(to: &tmp) {
    $0[1] = 0xdeadbeef
  }
  x = tmp                         // move, inserted by the compiler
}

Implementation: the inserted move is a copy_addr [take] to [initialization] which compiles down to a simple move. This even works with generic types (which are potentially not bitwise movable, e.g. weak ObjectiveC references). In this case the “initializeWithTake” value witness function is called which in most cases does a simple memcpy.

This approach has the benefit that we only have to pay for stack protection if there is a risk of an overflow, but not for the majority of regular “safe” Swift code.
The downside is that moving a value to a temporary stack location and back again can be slow in case of large values. Also, we are losing pointer identity, but Swift does not guarantee that anyway.

Escape Hatch

In some cases the user might want to opt out of stack protection, e.g. if moving a value causes too much performance overhead or if pointer identity must be preserved.

Stack protection for a specific address-to-pointer conversion can be disabled by using “unprotected” versions of withUnsafePointer:

  • _withUnprotectedUnsafeMutablePointer
  • _withUnprotectedUnsafePointer
  • _withUnprotectedUnsafeBytes

Those functions are underscored for now, but might be go through a swift evolution proposal.

Originally, I considered a function attribute, e.g. _noStackProtection. But this is problematic because it might get lost during function inlining. Also, I think it’s better to control the behavior of specific pointers rather than all pointers in a function.

In addition to this fine grained opt-out mechanism, it’s also possible to disable stack protection with a command line option for the whole compilation unit.

Debug Builds

Stack protection should also be enabled in debug builds. The main reason is that in case the compiler has to move an argument (see above), this should also be done in a debug build so that this behavior is tested (tests are usually run with debug builds).
In fact, in debug builds all pointers will be moved, because the library functions withUnsafePointer et al are not inlined and the move is done in those library functions.

Initial Implementation

An initial implementation is here: https://github.com/apple/swift/pull/60933

19 Likes

I think you mean "does not guarantee pointer identity anyway."

3 Likes

Can you share any numbers about this? Like, for some example projects, what is the code-size impact of unconditional stack protection, and how does that change with this conditional protection?

We saw code size increases of ~5% for unconditional stack protection. With this approach there is no cost at all, unless unsafe pointer operations are used.

2 Likes

Was that on publicly available projects? If not, could you share approximately how many LOCs these projects had?

Sorry, no it's not a public project.

I've noticed this runtime stack protection happening even in places where I would expect the compiler to provide things safe statically.

I'm using withUnsafeBytes(of: self) to work around the "outlined init with take" issue. It's important that I do that, because just applying it to one tiny enum improved the performance of lots of complex processing by up to 24%. Obviously I don't like to write this kind of code, and I don't recommend anybody does that by default, but it's a work-around for a demonstrable issue.

And it's still an issue, as of the latest nightly:

enum MyEnum {
    case one
    case four
}

struct HasAnEnum {
    var something: String
    var somethingTwo: String
    var value: MyEnum?
}

func test(_ input: HasAnEnum) -> Int {
    // This load results in very expensive runtime calls!
    let x = input.value
    if case .four = x { return 4 }
    return -1
}

func test2(_ input: HasAnEnum) -> Int {
    // Super ugly, but we can wrap this in a computed property to contain it.
    // It's so much faster.
    let x = withUnsafeBytes(of: input) {
        let offset = MemoryLayout<HasAnEnum>.offset(of: \.value)!
        return $0.load(fromByteOffset: offset, as: MyEnum?.self)
    }
    if case .four = x { return 4 }
    return -1
}

Godbolt

Unfortunately, I'm seeing that this load requires runtime stack protection checks. It seems strange - we get the offset from MemoryLayout, and the compiler constant-folds that (not seeing any runtime calls), and there doesn't seem to be any complex inter-procedural stuff going on. I'd expect the compiler to know that this is in-bounds.

Is this something that could reasonably be improved? Or does this involve some complex analysis that I'm not appreciating?

1 Like

We seem to be missing _withUnprotectedUnsafeMutableBytes (as a counterpart of withUnsafeMutableBytes). Only the non-mutating version is available.

Here's another example of a function which the compiler thinks needs stack protection, even though clearly no out-of-bounds writes can occur.

func test() -> UInt64 {
    UInt64(repeatingByte: 0x2F)
}

extension UInt64 {

  /// Creates an 8-byte integer, each of which is equal to the given byte.
  ///
  @inlinable @inline(__always)
  internal init(repeatingByte byte: UInt8) {
    self = 0
    withUnsafeMutableBytes(of: &self) {
      $0[0] = byte
      $0[1] = byte
      $0[2] = byte
      $0[3] = byte
      $0[4] = byte
      $0[5] = byte
      $0[6] = byte
      $0[7] = byte
    }
  }
}

In fact, for the function test(), the compiler constant-folds all of the code using unsafe pointers, but still keeps stack protection. Compiled on nightly at -O.

output.test() -> Swift.UInt64:
        push    rax
        mov     rax, qword ptr fs:[40]
        mov     qword ptr [rsp], rax
        mov     rax, qword ptr fs:[40]
        cmp     rax, qword ptr [rsp]
        jne     .LBB1_2
        movabs  rax, 3399988123389603631     ; 0x2F2F2F2F2F2F2F2F
        pop     rcx
        ret
.LBB1_2:
        call    __stack_chk_fail@PLT

And on 5.7:

output.test() -> Swift.UInt64:
        movabs  rax, 3399988123389603631
        ret

Godbolt

3 Likes