Skip to content

Commit

Permalink
Ensure that symlinks are not escaping the unpack directory unless the…
Browse files Browse the repository at this point in the history
… user states it explicitly.
  • Loading branch information
Imre Mihaly committed Feb 20, 2023
1 parent 599f998 commit 2e97b4d
Show file tree
Hide file tree
Showing 3 changed files with 122 additions and 31 deletions.
17 changes: 16 additions & 1 deletion Example/ObjectiveCExampleTests/SSZipArchiveTests.m
Expand Up @@ -363,7 +363,7 @@ - (void)testUnzippingWithSymlinkedFileInside {
NSString *outputPath = [self _cachesPath:@"SymbolicLink"];

id<SSZipArchiveDelegate> delegate = [ProgressDelegate new];
BOOL success = [SSZipArchive unzipFileAtPath:zipPath toDestination:outputPath delegate:delegate];
BOOL success = [SSZipArchive unzipFileAtPath:zipPath toDestination:outputPath preserveAttributes:YES overwrite:YES symlinksValidWithin:nil nestedZipLevel:0 password:nil error:nil delegate:delegate progressHandler:nil completionHandler:nil];
XCTAssertTrue(success, @"unzip failure");

NSString *testSymlink = [outputPath stringByAppendingPathComponent:@"SymbolicLink/Xcode.app"];
Expand All @@ -374,6 +374,21 @@ - (void)testUnzippingWithSymlinkedFileInside {
XCTAssertTrue(fileIsSymbolicLink, @"Symbolic links should persist from the original archive to the outputted files.");
}

- (void)testUnzippingWithSymlinkedFileEscapingOutputDirectory {

NSString *zipPath = [[NSBundle bundleForClass:[self class]] pathForResource:@"SymbolicLink" ofType:@"zip"];
NSString *outputPath = [self _cachesPath:@"SymbolicLink"];

id<SSZipArchiveDelegate> delegate = [ProgressDelegate new];
NSError *error = nil;
BOOL success = [SSZipArchive unzipFileAtPath:zipPath toDestination:outputPath overwrite:YES password:nil error:&error delegate:delegate];

XCTAssertFalse(success, @"Escaping symlink unpacked");
XCTAssertNotNil(error, @"Error not reported");
XCTAssertEqualObjects(error.domain, SSZipArchiveErrorDomain, @"Invalid error domain");
XCTAssertEqual(error.code, SSZipArchiveErrorCodeSymlinkEscapesTargetDirectory, @"Invalid error code");
}

- (void)testUnzippingWithRelativeSymlink {

NSString *resourceName = @"RelativeSymbolicLink";
Expand Down
25 changes: 19 additions & 6 deletions SSZipArchive/SSZipArchive.h
Expand Up @@ -16,12 +16,13 @@ NS_ASSUME_NONNULL_BEGIN

extern NSString *const SSZipArchiveErrorDomain;
typedef NS_ENUM(NSInteger, SSZipArchiveErrorCode) {
SSZipArchiveErrorCodeFailedOpenZipFile = -1,
SSZipArchiveErrorCodeFailedOpenFileInZip = -2,
SSZipArchiveErrorCodeFileInfoNotLoadable = -3,
SSZipArchiveErrorCodeFileContentNotReadable = -4,
SSZipArchiveErrorCodeFailedToWriteFile = -5,
SSZipArchiveErrorCodeInvalidArguments = -6,
SSZipArchiveErrorCodeFailedOpenZipFile = -1,
SSZipArchiveErrorCodeFailedOpenFileInZip = -2,
SSZipArchiveErrorCodeFileInfoNotLoadable = -3,
SSZipArchiveErrorCodeFileContentNotReadable = -4,
SSZipArchiveErrorCodeFailedToWriteFile = -5,
SSZipArchiveErrorCodeInvalidArguments = -6,
SSZipArchiveErrorCodeSymlinkEscapesTargetDirectory = -7,
};

@protocol SSZipArchiveDelegate;
Expand Down Expand Up @@ -83,6 +84,18 @@ typedef NS_ENUM(NSInteger, SSZipArchiveErrorCode) {
progressHandler:(void (^_Nullable)(NSString *entry, unz_file_info zipInfo, long entryNumber, long total))progressHandler
completionHandler:(void (^_Nullable)(NSString *path, BOOL succeeded, NSError * _Nullable error))completionHandler;

+ (BOOL)unzipFileAtPath:(NSString *)path
toDestination:(NSString *)destination
preserveAttributes:(BOOL)preserveAttributes
overwrite:(BOOL)overwrite
symlinksValidWithin:(nullable NSString *)symlinksValidWithin
nestedZipLevel:(NSInteger)nestedZipLevel
password:(nullable NSString *)password
error:(NSError **)error
delegate:(nullable id<SSZipArchiveDelegate>)delegate
progressHandler:(void (^_Nullable)(NSString *entry, unz_file_info zipInfo, long entryNumber, long total))progressHandler
completionHandler:(void (^_Nullable)(NSString *path, BOOL succeeded, NSError * _Nullable error))completionHandler;

// Zip
// default compression level is Z_DEFAULT_COMPRESSION (from "zlib.h")
// keepParentDirectory: if YES, then unzipping will give `directoryName/fileName`. If NO, then unzipping will just give `fileName`. Default is NO.
Expand Down
111 changes: 87 additions & 24 deletions SSZipArchive/SSZipArchive.m
Expand Up @@ -31,6 +31,7 @@ - (NSString *)_hexString;

@interface NSString (SSZipArchive)
- (NSString *)_sanitizedPath;
- (BOOL)_escapesTargetDirectory:(NSString *)targetDirectory;
@end

@interface SSZipArchive ()
Expand Down Expand Up @@ -290,6 +291,32 @@ + (BOOL)unzipFileAtPath:(NSString *)path
delegate:(nullable id<SSZipArchiveDelegate>)delegate
progressHandler:(void (^_Nullable)(NSString *entry, unz_file_info zipInfo, long entryNumber, long total))progressHandler
completionHandler:(void (^_Nullable)(NSString *path, BOOL succeeded, NSError * _Nullable error))completionHandler
{
return [self unzipFileAtPath:path
toDestination:destination
preserveAttributes:preserveAttributes
overwrite:overwrite
symlinksValidWithin:destination
nestedZipLevel:nestedZipLevel
password:password
error:error
delegate:delegate
progressHandler:progressHandler
completionHandler:completionHandler];
}


+ (BOOL)unzipFileAtPath:(NSString *)path
toDestination:(NSString *)destination
preserveAttributes:(BOOL)preserveAttributes
overwrite:(BOOL)overwrite
symlinksValidWithin:(nullable NSString *)symlinksValidWithin
nestedZipLevel:(NSInteger)nestedZipLevel
password:(nullable NSString *)password
error:(NSError **)error
delegate:(nullable id<SSZipArchiveDelegate>)delegate
progressHandler:(void (^_Nullable)(NSString *entry, unz_file_info zipInfo, long entryNumber, long total))progressHandler
completionHandler:(void (^_Nullable)(NSString *path, BOOL succeeded, NSError * _Nullable error))completionHandler
{
// Guard against empty strings
if (path.length == 0 || destination.length == 0)
Expand Down Expand Up @@ -525,6 +552,7 @@ + (BOOL)unzipFileAtPath:(NSString *)path
toDestination:fullPath.stringByDeletingLastPathComponent
preserveAttributes:preserveAttributes
overwrite:overwrite
symlinksValidWithin:symlinksValidWithin
nestedZipLevel:nestedZipLevel - 1
password:password
error:nil
Expand Down Expand Up @@ -637,34 +665,47 @@ + (BOOL)unzipFileAtPath:(NSString *)path
break;
}

// Check if the symlink exists and delete it if we're overwriting
if (overwrite)
{
if ([fileManager fileExistsAtPath:fullPath])
{
NSError *localError = nil;
BOOL removeSuccess = [fileManager removeItemAtPath:fullPath error:&localError];
if (!removeSuccess)
{
NSString *message = [NSString stringWithFormat:@"Failed to delete existing symbolic link at \"%@\"", localError.localizedDescription];
NSLog(@"[SSZipArchive] %@", message);
success = NO;
unzippingError = [NSError errorWithDomain:SSZipArchiveErrorDomain code:localError.code userInfo:@{NSLocalizedDescriptionKey: message}];
}
}
// compose symlink full path
NSString *symlinkFullDestinationPath = destinationPath;
if (![symlinkFullDestinationPath isAbsolutePath]) {
symlinkFullDestinationPath = [[fullPath stringByDeletingLastPathComponent] stringByAppendingPathComponent:destinationPath];
}

// Create the symbolic link (making sure it stays relative if it was relative before)
int symlinkError = symlink([destinationPath cStringUsingEncoding:NSUTF8StringEncoding],
[fullPath cStringUsingEncoding:NSUTF8StringEncoding]);

if (symlinkError != 0)
{
// Bubble the error up to the completion handler
NSString *message = [NSString stringWithFormat:@"Failed to create symbolic link at \"%@\" to \"%@\" - symlink() error code: %d", fullPath, destinationPath, errno];
if (symlinksValidWithin != nil && [symlinkFullDestinationPath _escapesTargetDirectory: symlinksValidWithin]) {
NSString *message = [NSString stringWithFormat:@"Symlink escapes target directory \"~%@ -> %@\"", strPath, destinationPath];
NSLog(@"[SSZipArchive] %@", message);
success = NO;
unzippingError = [NSError errorWithDomain:NSPOSIXErrorDomain code:symlinkError userInfo:@{NSLocalizedDescriptionKey: message}];
unzippingError = [NSError errorWithDomain:SSZipArchiveErrorDomain code:SSZipArchiveErrorCodeSymlinkEscapesTargetDirectory userInfo:@{NSLocalizedDescriptionKey: message}];
} else {
// Check if the symlink exists and delete it if we're overwriting
if (overwrite)
{
if ([fileManager fileExistsAtPath:fullPath])
{
NSError *localError = nil;
BOOL removeSuccess = [fileManager removeItemAtPath:fullPath error:&localError];
if (!removeSuccess)
{
NSString *message = [NSString stringWithFormat:@"Failed to delete existing symbolic link at \"%@\"", localError.localizedDescription];
NSLog(@"[SSZipArchive] %@", message);
success = NO;
unzippingError = [NSError errorWithDomain:SSZipArchiveErrorDomain code:localError.code userInfo:@{NSLocalizedDescriptionKey: message}];
}
}
}

// Create the symbolic link (making sure it stays relative if it was relative before)
int symlinkError = symlink([destinationPath cStringUsingEncoding:NSUTF8StringEncoding],
[fullPath cStringUsingEncoding:NSUTF8StringEncoding]);

if (symlinkError != 0)
{
// Bubble the error up to the completion handler
NSString *message = [NSString stringWithFormat:@"Failed to create symbolic link at \"%@\" to \"%@\" - symlink() error code: %d", fullPath, destinationPath, errno];
NSLog(@"[SSZipArchive] %@", message);
success = NO;
unzippingError = [NSError errorWithDomain:NSPOSIXErrorDomain code:symlinkError userInfo:@{NSLocalizedDescriptionKey: message}];
}
}
}

Expand Down Expand Up @@ -1430,4 +1471,26 @@ - (NSString *)_sanitizedPath
return strPath;
}

/// Detects if the path represented in this string is pointing outside of the targetDirectory passed as argument.
///
/// Helps detecting and avoiding a security vulnerability described here:
/// https://nvd.nist.gov/vuln/detail/CVE-2022-36943
- (BOOL)_escapesTargetDirectory:(NSString *)targetDirectory {
NSString *standardizedPath = [[self stringByStandardizingPath] stringByResolvingSymlinksInPath];
NSString *standardizedTargetPath = [[targetDirectory stringByStandardizingPath] stringByResolvingSymlinksInPath];

NSArray *targetPathComponents = [standardizedTargetPath pathComponents];
NSArray *pathComponents = [standardizedPath pathComponents];

if (pathComponents.count < targetPathComponents.count) return YES;

for (int idx = 0; idx < targetPathComponents.count; idx++) {
if (![pathComponents[idx] isEqual: targetPathComponents[idx]]) {
return YES;
}
}

return NO;
}

@end

0 comments on commit 2e97b4d

Please sign in to comment.