In my previous article about secure coding in Swift, I discussed basic security vulnerabilities in Swift such as injection attacks. While injection attacks are common, there are other ways your app can be compromised. A common but sometimes-overlooked kind of vulnerability is race conditions.
Swift 4 introduces Exclusive Access to Memory, which consists of a set of rules to prevent the same area of memory being accessed at the same time. For example, the inout
argument in Swift tells a method that it can change the value of the parameter inside the method.
func changeMe(_ x : inout MyObject, andChange y : inout MyObject)
But what happens if we pass in the same variable to change at the same time?
changeMe(&myObject, andChange:&myObject) // ???
Swift 4 has made improvements that prevent this from compiling. But while Swift can find these obvious scenarios at compile time, it is difficult, especially for performance reasons, to find memory access problems in concurrent code, and most of the security vulnerabilities exist in the form of race conditions.
Race Conditions
As soon as you have more than one thread that needs to write to the same data at the same time, a race condition can occur. Race conditions cause data corruption. For these types of attacks, the vulnerabilities are usually more subtle—and the exploits more creative. For instance, there might be the ability to alter a shared resource to change the flow of security code happening on another thread, or in the case of authentication status, an attacker might be able to take advantage of a time gap between the time of check and the time of use of a flag.
The way to avoid race conditions is to synchronize the data. Synchronizing data usually means to "lock" it so that only one thread can access that part of the code at a time (said to be a mutex—for mutual exclusion). While you can do this explicitly using the NSLock
class, there is potential to miss places where the code should have been synchronized. Keeping track of the locks and whether they are already locked or not can be difficult.
Grand Central Dispatch
Instead of using primitive locks, you can use Grand Central Dispatch (GCD)—Apple's modern concurrency API designed for performance and security. You don't need to think about the locks yourself; it does the work for you behind the scenes.
DispatchQueue.global(qos: .background).async //concurrent queue, shared by system { //do long running work in the background here //... DispatchQueue.main.async //serial queue { //Update the UI - show the results back on the main thread } }
As you can see, it's quite a simple API, so use GCD as your first choice when designing your app for concurrency.
Swift's runtime security checks cannot be performed across GCD threads because it creates a significant performance hit. The solution is to use the Thread Sanitizer tool if you are working with multiple threads. The Thread Sanitizer tool is great at finding problems you might never find by looking at the code yourself. It can be enabled by going to Product > Scheme > Edit Scheme > Diagnostics, and checking the Thread Sanitizer option.
If the design of your app makes you work with multiple threads, another way to protect yourself from the security issues of concurrency is to try to design your classes to be lock free so that no synchronization code is necessary in the first place. This requires some real thought about the design of your interface, and can even be considered a separate art in and of itself!
The Main Thread Checker
It is important to mention that data corruption can also occur if you do UI updates on any thread other than the main thread (any other thread is referred to as a background thread).
Sometimes it's not even obvious you are on a background thread. For example, NSURLSession
's delegateQueue
, when set to nil
, will by default call back on a background thread. If you do UI updates or write to your data in that block, there is a good chance for race conditions. (Fix this by wrapping the UI updates in DispatchQueue.main.async {}
or pass in OperationQueue.main
as the delegate queue.)
New in Xcode 9 and enabled by default is the Main Thread Checker (Product > Scheme > Edit Scheme > Diagnostics > Runtime API Checking > Main Thread Checker). If your code is not synchronized, issues will show up in the Runtime Issues on the left pane navigator of Xcode, so pay attention to it while testing your app.
To code for security, any callbacks or completion handlers that you write should be documented whether they return on the main thread or not. Better yet, follow Apple's newer API design which lets you pass a completionQueue
in the method so you can clearly decide and see what thread the completion block returns on.
A Real-World Example
Enough talk! Let's dive into an example.
class Transaction { //... } class Transactions { private var lastTransaction : Transaction? func addTransaction(_ source : Transaction) { //... lastTransaction = source } } //First thread transactions.addTransaction(transaction) //Second thread transactions.addTransaction(transaction)
Here we have no synchronization, but more than one thread accesses the data at the same time. The good thing about Thread Sanitizer is that it will detect a case like this. The modern GCD way to fix this is to associate your data with a serial dispatch queue.
class Transactions { private var lastTransaction : Transaction? private var queue = DispatchQueue(label: "com.myCompany.myApp.bankQueue") func addTransaction(_ source : Transaction) { queue.async { //... self.lastTransaction = source } } }
Now the code is synchronized with the .async
block. You might be wondering when to choose .async
and when to use .sync
. You can use .async
when your app doesn't need to wait until the operation inside the block is finished. It might be better explained with an example.
let queue = DispatchQueue(label: "com.myCompany.myApp.bankQueue") var transactionIDs : [String] = ["00001", "00002"] //First thread queue.async { transactionIDs.append("00003") } //not providing any output so don't need to wait for it to finish //Another thread queue.sync { if transactionIDs.contains("00001") //...Need to wait here! { print("Transaction already completed") } }
In this example, the thread that asks the transaction array if it contains a specific transaction provides output, so it needs to wait. The other thread doesn't take any action after appending to the transaction array, so it doesn't need to wait until the block is completed.
These sync and async blocks can be wrapped in methods that return your internal data, such as getter methods.
get { return queue.sync { transactionID } }
Scattering GCD blocks all over the areas of your code that access shared data is not a good practice as it is harder to keep track of all the places that need to be synchronized. It’s much better to try and keep all this functionality in one place.
Good design using accessor methods is one way to solve this problem. Using getter and setter methods and only using these methods to access the data means that you can synchronize in one place. This avoids having to update many parts of your code if you are changing or refactoring the GCD area of your code.
Structs
While single stored properties can be synchronized in a class, changing properties on a struct will actually affect the entire struct. Swift 4 now includes protection for methods that mutate the structs.
Let's first look at what a struct corruption (called a "Swift access race") looks like.
struct Transaction { private var id : UInt32 private var timestamp : Double //... mutating func begin() { id = arc4random_uniform(101) // 0 - 100 //... } mutating func finish() { //... timestamp = NSDate().timeIntervalSince1970 } }
The two methods in the example change the stored properties, so they are marked mutating
. Lets say thread 1 calls begin()
and thread 2 calls finish()
. Even if begin()
only changes id
and finish()
only changes timestamp
, it's still an access race. While normally it's better to lock inside accessor methods, this doesn't apply to structs as the entire struct needs to be exclusive.
One solution is to change the struct to a class when implementing your concurrent code. If you needed the struct for some reason, you could, in this example, create a Bank
class which stores Transaction
structs. Then the callers of the structs inside the class can be synchronized.
Here is an example:
class Bank { private var currentTransaction : Transaction? private var queue : DispatchQueue = DispatchQueue(label: "com.myCompany.myApp.bankQueue") func doTransaction() { queue.sync { currentTransaction?.begin() //... } } }
Access Control
It would be pointless to have all this protection when your interface exposes a mutating object or an UnsafeMutablePointer
to the shared data, because now any user of your class can do whatever they want with the data without the protection of GCD. Instead, return copies to the data in the getter. Careful interface design and data encapsulation are important, especially when designing concurrent programs, to make sure that the shared data is really protected.
Make sure the synchronized variables are marked private
, as opposed to open
or public
, which would allow members from any source file to access it. One interesting change in Swift 4 is that the private
access level scope is expanded to be available in extensions. Previously it could only be used within the enclosing declaration, but in Swift 4, a private
variable can be accessed in an extension, as long as the extension of that declaration is in the same source file.
Not only are variables at risk for data corruption but files as well. Use the FileManager
Foundation class, which is thread-safe, and check the result flags of its file operations before continuing in your code.
Interfacing With Objective-C
Many Objective-C objects have a mutable counterpart depicted by their title. NSString
's mutable version is named NSMutableString
, NSArray
's is NSMutableArray
, and so on. Besides the fact that these objects can be mutated outside of synchronization, pointer types coming from Objective-C also subvert Swift optionals. There is a good chance that you could be expecting an object in Swift, but from Objective-C it is returned as nil.
If the app crashes, it gives valuable insight into the internal logic. In this case, it could be that user input was not properly checked and that area of the app flow is worth looking at to try and exploit.
The solution here is to update your Objective-C code to include nullability annotations. We can take a slight diversion here as this advice applies to safe interoperability in general, whether between Swift and Objective-C or between two other programming languages.
Preface your Objective-C variables with nullable
when nil can be returned, and nonnull
when it shouldn't.
- (nonnull NSString *)myStringFromString:(nullable NSString *)string;
You can also add nullable
and nonnull
to the attribute list of Objective-C properties.
@property (nullable, atomic, strong) NSDate *date;
The Static Analyzer tool in Xcode has always been great for finding Objective-C bugs. Now with nullability annotations, in Xcode 9 you can use the Static Analyzer on your Objective-C code and it will find nullability inconsistencies in your file. Do this by navigating to Product > Perform Action > Analyze.
While it's enabled by default, you can also control the nullability checks in LLVM with -Wnullability*
flags.
Nullability checks are good for finding issues at compile time, but they don't find runtime issues. For example, sometimes we assume in a part of our code that an optional value will always exist and use the force unwrap !
on it. This is an implicitly unwrapped optional, but there is really no guarantee that it will always exist. After all, if it were marked optional, it's likely to be nil at some point. Therefore, it's a good idea to avoid force unwrapping with !
. Instead, an elegant solution is to check at runtime like so:
guard let dog = animal.dog() else { //handle this case return } //continue...
To further help you out, there is a new feature added in Xcode 9 to perform nullability checks at runtime. It is part of the Undefined Behavior Sanitizer, and while it's not enabled by default, you can enable it by going to Build Settings > Undefined Behavior Sanitizer and setting Yes for Enable Nullability Annotation Checks.
Readability
It’s good practice to write your methods with only one entry and one exit point. Not only is this good for readability, but also for advanced multithreading support.
Let's say a class was designed without concurrency in mind. Later the requirements changed so that it must now support the .lock()
and .unlock()
methods of NSLock
. When it comes time to place locks around parts of your code, you may need to rewrite a lot of your methods just to be thread-safe. It's easy to miss a return
hidden in the middle of a method that was later supposed to lock your NSLock
instance, which can then cause a race condition. Also, statements such as return
will not automatically unlock the lock. Another part of your code that assumes the lock is unlocked and tries to lock again will deadlock the app (the app will freeze and eventually be terminated by the system). Crashes can also be security vulnerabilities in multithreaded code if temporary work files are never cleaned up before the thread terminates. If your code has this structure:
if x if y return true else return false ... return false
You can instead store the Boolean, update it along the way and then return it at the end of the method. Then synchronization code can easily be wrapped in the method without much work.
var success = false // <--- lock if x if y success = true ... // < --- unlock return success
The .unlock()
method must be called from the same thread that called .lock()
, otherwise it results in undefined behavior.
Testing
Often, finding and fixing vulnerabilities in concurrent code comes down to bug hunting. When you find a bug, it's like holding a mirror up to yourself—a great learning opportunity. If you forgot to synchronize in one place, it's likely that the same mistake is elsewhere in the code. Taking the time to check the rest of your code for the same mistake when you encounter a bug is a very efficient way of preventing security vulnerabilities that would keep appearing over and over again in future app releases.
In fact, many of the recent iOS jailbreaks have been because of repeated coding mistakes found in Apple's IOKit. Once you know the developer's style, you can check other parts of the code for similar bugs.
Bug finding is good motivation for code reuse. Knowing that you fixed a problem in one place and don't have to go find all the same occurrences in copy/paste code can be a big relief.
Race conditions can be complicated to find during testing because memory might have to be corrupted in just the “right way” in order to see the problem, and sometimes the problems appear a long time later in the app's execution.
When you are testing, cover all your code. Go through each flow and case and test each line of code at least once. Sometimes it helps to input random data (fuzzing the inputs), or choose extreme values in hopes of finding an edge case that would not be obvious from looking at the code or using the app in a normal way. This, along with the new Xcode tools available, can go a long way towards preventing security vulnerabilities. While no code is 100% secure, following a routine, such as early-on functional tests, unit tests, system test, stress and regression tests, will really pay off.
Beyond debugging your app, one thing that is different for the release configuration (the configuration for apps published on the store) is that code optimizations are included. For example, what the compiler thinks is an unused operation can get optimized out, or a variable may not stick around longer than necessary in a concurrent block. For your published app, your code is actually changed, or different from the one that you tested. This means that bugs can be introduced that only exist once you release your app.
If you are not using a test configuration, make sure you test your app on release mode by navigating to Product > Scheme > Edit Scheme. Select Run from the list on the left, and in the Info pane on the right, change Build Configuration to Release. While it's good to cover your entire app in this mode, know that because of optimizations, the breakpoints and the debugger will not behave as expected. For example, variable descriptions might not be available even though the code is executing correctly.
Conclusion
In this post, we looked at race conditions and how to avoid them by coding securely and using tools like the Thread Sanitizer. We also talked about Exclusive Access to Memory, which is a great addition to Swift 4. Make sure it's set to Full Enforcement in Build Settings > Exclusive Access to Memory!
Remember that these enforcements are only on for debug mode, and if you are still using Swift 3.2, many of the enforcements discussed come in the form of warnings only. So take the warnings seriously, or better yet, make use of all the new features available by adopting Swift 4 today!
And while you're here, check out some of my other posts on secure coding for iOS and Swift!
-
iOS SDKSecuring Communications on iOS
-
iOS SDKSecuring iOS Data at Rest: Protecting the User's Data
-
SecurityCreating Digital Signatures With Swift
-
SecuritySecure Coding in Swift 4
No comments:
Post a Comment