Swift deserialization security primer
Sam Sanoop
18 juillet 2023
0 minutes de lectureDeserialization is the process of converting data from a serialized format, such as JSON or binary, back into its original form. Swift provides multiple protocols allowing users to convert objects and values to and from property lists, JSON, and other flat binary representations.
Deserialization can also introduce unsuspecting security vulnerabilities in a user’s codebase that attackers could exploit. This blog will detail deserialization vulnerabilities in Swift that can occur when using the popular APIs, NScoding and NSSecureCoding, and how to prevent it properly.
NSCoding
NSCoding is a protocol Apple provides that can be used for serialization and deserialization of data. The default protocol to serialize and deserialize data on NSCoding is vulnerable to object substitution attacks — which could allow an attacker to leverage remote code execution.
Take the following class, which implements NSCoding with two methods — initWithCoder:
and encodeWithCoder:
. Classes conforming to NSCoding can be serialized and deserialized into data that can be archived to a disk or distributed across a network.
1import Foundation
2
3class Employee: NSObject, NSCoding {
4 var name: String
5 var role: String
6
7 init(name: String, role: String) {
8 self.name = name
9 self.role = role
10 }
11
12 required convenience init?(coder aDecoder: NSCoder) {
13 guard let name = aDecoder.decodeObject(forKey: "name") as? String,
14 let role = aDecoder.decodeObject(forKey: "role") as? String else {
15 return nil
16 }
17
18 self.init(name: name, role: role)
19 }
20
21 func encode(with aCoder: NSCoder) {
22 aCoder.encode(name, forKey: "name")
23 aCoder.encode(role, forKey: "role")
24 }
25}
The above class conforms to the NSCoding protocol and has two properties — name and role. This class also implements the required init(coder:)
initializer to decode the name and role properties from an archive and the encode(with:)
method to encode the name and role properties into an archive.
An employee object can then be created and archived to a file using NSKeyedArchiver.
1// Archive the Employee object to a file
2let person = Employee(name: "John", role: "consultant")
3let fileURL = URL(fileURLWithPath: "/tmp/file")
4NSKeyedArchiver.archiveRootObject(person, toFile: fileURL.path)
This can then be unarchived to the employee object from the file using NSKeyedUnarchiver to print the values of the name and role properties.
1// Unarchive the Employee object from the file
2if let unarchivedPerson = NSKeyedUnarchiver.unarchiveObject(withFile: "/tmp/file") as? Employee {
3 print("Name: \(unarchivedPerson.name), Role: \(unarchivedPerson.role)")
4} else {
5 print("Failed to unarchive Employee object")
6}
This can be potentially exploited in the following way. Assume other classes exist in the codebase, including the following class. This class has a single property called "command" — a string representing a file path. The class also has two methods, "encode(with:)" and "init?(coder:)", which are required to conform to the NSCoding protocol.
The command property is taken from an encoded object and flows into the sink1
function — which executes the property as part of a command.
1import Foundation
2
3class ExampleGadget: NSObject, NSCoding {
4
5 let command: String
6
7 internal init(command: String) {
8 self.command = command
9 }
10
11 func encode(with coder: NSCoder) {
12 coder.encode(command, forKey: "command")
13 }
14
15 required init?(coder: NSCoder) {
16 command = coder.decodeObject(forKey: "command") as! String
17
18 super.init()
19 var result = sink1(tainted: command)
20 print(result)
21 }
22
23 func sink1(tainted: String) -> String {
24
25 let process = Process()
26 process.executableURL = URL(fileURLWithPath: "/bin/bash")
27 process.arguments = ["-c", tainted]
28 let pipe = Pipe()
29 process.standardOutput = pipe
30 process.launch()
31 process.waitUntilExit()
32 let data = pipe.fileHandleForReading.readDataToEndOfFile()
33 guard let output: String = String(data: data, encoding: .utf8) else { return "" }
34 return output
35
36}
37}
An attacker can leverage this class to execute code and conduct a deserialization attack that can lead to a command injection.
NSSecureCoding
NSSecureCoding is a secure alternative to NSCoding that provides enhanced security features to safeguard against deserialization attacks. Unlike NSCoding, NSSecureCoding imposes stricter security requirements on the encoded and decoded objects, including the need for a defined class hierarchy and specific security measures to be implemented in the classes themselves. These added measures help prevent the manipulation of serialized data that could result in the creation of objects capable of executing malicious code, thereby enhancing the system's overall security. However, deserialization attacks might still occur depending on how it's used.
Decoding without verifying the class type
In the below example, the code has been changed to conform to NSSecureCoding. The supportSecureCoding
property has been set to true, and just like before, the decodeObject
method is used to deserialize encoded objects.
1import Foundation
2
3class Employee: NSObject, NSSecureCoding {
4
5 public static var supportsSecureCoding = true
6 var name: String
7 var role: String
8
9 init(name: String, role: String) {
10 self.name = name
11 self.role = role
12 }
13
14 required convenience init?(coder aDecoder: NSCoder) {
15 guard let name = aDecoder.decodeObject(forKey: "name") as? String,
16 let role = aDecoder.decodeObject(of:Employee.self, forKey: "role") as? String else {
17 return nil
18 }
19
20 self.init(name: name, role: role)
21 }
22
23 func encode(with aCoder: NSCoder) {
24 aCoder.encode(name, forKey: "name")
25 aCoder.encode(role, forKey: "role")
26 }
27}
However, as stated in the Apple developer documentation for NSSecureCoding, this technique is potentially unsafe because, by the time you verify the class type, the object has already been constructed — and if this is part of a collection class, potentially inserted into an object graph. Setting supportsSecureCoding
to true
tags an object as safe to decode. However, no verification is done by NSSecureCoding to verify the type of this object and whether this relates to the relevant employee class.
To remediate this issue, the of
key can be specified with decodeObject(of:Employee.self, forKey: "name")
, which only decodes objects of the specified class (in this case, employee). This ensures that the name property can only be decoded as a string, not as a maliciously crafted object. Alternatively, the decodeObjectOfClass
method can be used — which decodes an object for the key restricted to the specified class. Furthermore, the decodeObjectForKey
and decodeTopLevelObjectForKey
methods should not be used, which is also affected by this issue and is considered deprecated by Apple.
supportsSecureCoding set to False
As stated in the Apple developer documentation, you should ensure that this class property's getter returns true
when writing a class that supports secure coding. Setting supportsSecureCoding = false
still conforms to NSSecureCoding and gives developers a false sense of security while allowing deserialization attacks.
Secure Swift deserialization
In summary, security considerations should be taken into account when deserializing user data using NSCoding and NSSecureCoding. While documentation might make it look like NSSecureCoding prevents deserialization by default, this is not the case, and instances exist where unsafe deserialization is still possible. Whenever using decode functions with NSSecureCoding, ensure that the type of the object being deserialized is verified
References
Example code used in this blog can be found here: https://github.com/snoopysecurity/swift-deserialization-security-primer