-
Notifications
You must be signed in to change notification settings - Fork 1.7k
C#: ZipSlip - Rearrange query, add help and update doc #81
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
80e4815
3bc035f
09b2387
b6c9f84
112d104
99d1cf7
fa78d04
0477bd7
d6c58d6
6959d80
6453153
4f57456
86a7df0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
<!DOCTYPE qhelp PUBLIC | ||
"-//Semmle//qhelp//EN" | ||
"qhelp.dtd"> | ||
<qhelp> | ||
<overview> | ||
<p>Extracting files from a malicious zip archive without validating that the destination file path | ||
is within the destination directory can cause files outside the destination directory to be | ||
overwritten, due to the possible presence of directory traversal elements (<code>..</code>) in | ||
archive paths.</p> | ||
|
||
<p>Zip archives contain archive entries representing each file in the archive. These entries | ||
include a file path for the entry, but these file paths are not restricted and may contain | ||
unexpected special elements such as the directory traversal element (<code>..</code>). If these | ||
file paths are used to determine an output file to write the contents of the archive item to, then | ||
the file may be written to an unexpected location. This can result in sensitive information being | ||
revealed or deleted, or an attacker being able to influence behavior by modifying unexpected | ||
files.</p> | ||
|
||
<p>For example, if a zip file contains a file entry <code>..\sneaky-file</code>, and the zip file | ||
is extracted to the directory <code>c:\output</code>, then naively combining the paths would result | ||
in an output file path of <code>c:\output\..\sneaky-file</code>, which would cause the file to be | ||
written to <code>c:\sneaky-file</code>.</p> | ||
|
||
</overview> | ||
<recommendation> | ||
|
||
<p>Ensure that output paths constructed from zip archive entries are validated to prevent writing | ||
files to unexpected locations.</p> | ||
|
||
<p>The recommended way of writing an output file from a zip archive entry is to:</p> | ||
|
||
<ol> | ||
<li>Use <code>Path.Combine(destinationDirectory, archiveEntry.FullName)</code> to determine the raw | ||
output path.</li> | ||
<li>Use <code>Path.GetFullPath(..)</code> on the raw output path to resolve any directory traversal | ||
elements.</li> | ||
<li>Use <code>Path.GetFullPath(destinationDirectory + Path.DirectorySeparatorChar)</code> to | ||
determine the fully resolved path of the destination directory.</li> | ||
<li>Validate that the resolved output path <code>StartsWith</code> the resolved destination | ||
directory, aborting if this is not true.</li> | ||
</ol> | ||
|
||
<p>Another alternative is to validate archive entries against a whitelist of expected files.</p> | ||
|
||
</recommendation> | ||
<example> | ||
|
||
<p>In this example, a file path taken from a zip archive item entry is combined with a | ||
destination directory. The result is used as the destination file path without verifying that | ||
the result is within the destination directory. If provided with a zip file containing an archive | ||
path like <code>..\sneaky-file</code>, then this file would be written outside the destination | ||
directory.</p> | ||
|
||
<sample src="ZipSlipBad.cs" /> | ||
|
||
<p>To fix this vulnerability, we need to make three changes. Firstly, we need to resolve any | ||
directory traversal or other special characters in the path by using <code>Path.GetFullPath</code>. | ||
Secondly, we need to identify the destination output directory, again using | ||
<code>Path.GetFullPath</code>, this time on the output directory. Finally, we need to ensure that | ||
the resolved output starts with the resolved destination directory, and throw an exception if this | ||
is not the case.</p> | ||
|
||
<sample src="ZipSlipGood.cs" /> | ||
|
||
</example> | ||
<references> | ||
|
||
<li> | ||
Snyk: | ||
<a href="https://snyk.io/research/zip-slip-vulnerability">Zip Slip Vulnerability</a>. | ||
</li> | ||
<li> | ||
OWASP: | ||
<a href="https://www.owasp.org/index.php/Path_traversal">Path Traversal</a>. | ||
</li> | ||
|
||
</references> | ||
</qhelp> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,90 +1,19 @@ | ||
/** | ||
* @name Potential ZipSlip vulnerability | ||
* @description When extracting files from an archive, don't add archive item's path to the target file system path. Archive path can be relative and can lead to | ||
* file system access outside of the expected file system target path, leading to malicious config changes and remote code execution via lay-and-wait technique | ||
* @name Arbitrary file write during zip extraction ("Zip Slip") | ||
* @description Extracting files from a malicious zip archive without validating that the | ||
* destination file path is within the destination directory can cause files outside | ||
* the destination directory to be overwritten. | ||
* @kind problem | ||
* @id cs/zipslip | ||
* @problem.severity error | ||
* @precision high | ||
* @tags security | ||
* external/cwe/cwe-022 | ||
*/ | ||
|
||
import csharp | ||
import semmle.code.csharp.security.dataflow.ZipSlip::ZipSlip | ||
|
||
// access to full name of the archive item | ||
Expr archiveFullName(PropertyAccess pa) { | ||
pa.getTarget().getDeclaringType().hasQualifiedName("System.IO.Compression.ZipArchiveEntry") | ||
and pa.getTarget().getName() = "FullName" | ||
and result = pa | ||
} | ||
|
||
// argument to extract to file extension method | ||
Expr compressionExtractToFileArgument(MethodCall mc) { | ||
mc.getTarget().hasQualifiedName("System.IO.Compression.ZipFileExtensions", "ExtractToFile") | ||
and result = mc.getArgumentForName("destinationFileName") | ||
} | ||
|
||
// File Stream created from tainted file name through File.Open/File.Create | ||
Expr fileOpenArgument(MethodCall mc) { | ||
(mc.getTarget().hasQualifiedName("System.IO.File", "Open") or | ||
mc.getTarget().hasQualifiedName("System.IO.File", "OpenWrite") or | ||
mc.getTarget().hasQualifiedName("System.IO.File", "Create")) | ||
and result = mc.getArgumentForName("path") | ||
} | ||
|
||
// File Stream created from tainted file name passed directly to the constructor | ||
Expr streamConstructorArgument(ObjectCreation oc) { | ||
oc.getTarget().getDeclaringType().hasQualifiedName("System.IO.FileStream") | ||
and result = oc.getArgumentForName("path") | ||
} | ||
|
||
// constructor to FileInfo can take tainted file name and subsequently be used to open file stream | ||
Expr fileInfoConstructorArgument(ObjectCreation oc) { | ||
oc.getTarget().getDeclaringType().hasQualifiedName("System.IO.FileInfo") | ||
and result = oc.getArgumentForName("fileName") | ||
} | ||
// extracting just file name, not the full path | ||
Expr fileNameExtraction(MethodCall mc) { | ||
mc.getTarget().hasQualifiedName("System.IO.Path", "GetFileName") | ||
and result = mc.getAnArgument() | ||
} | ||
|
||
// Checks the string for relative path, or checks the destination folder for whitelisted/target path, etc. | ||
Expr stringCheck(MethodCall mc) { | ||
(mc.getTarget().hasQualifiedName("System.String", "StartsWith") or | ||
mc.getTarget().hasQualifiedName("System.String", "Substring")) | ||
and result = mc.getQualifier() | ||
} | ||
|
||
// Taint tracking configuration for ZipSlip | ||
class ZipSlipTaintTrackingConfiguration extends TaintTracking::Configuration { | ||
ZipSlipTaintTrackingConfiguration() { | ||
this = "ZipSlipTaintTracking" | ||
} | ||
|
||
override predicate isSource(DataFlow::Node source) { | ||
exists(PropertyAccess pa | | ||
source.asExpr() = archiveFullName(pa)) | ||
} | ||
|
||
override predicate isSink(DataFlow::Node sink) { | ||
exists(MethodCall mc | | ||
sink.asExpr() = compressionExtractToFileArgument(mc) or | ||
sink.asExpr() = fileOpenArgument(mc)) | ||
or | ||
exists(ObjectCreation oc | | ||
sink.asExpr() = streamConstructorArgument(oc) or | ||
sink.asExpr() = fileInfoConstructorArgument(oc)) | ||
} | ||
|
||
override predicate isSanitizer(DataFlow::Node node) { | ||
exists(MethodCall mc | | ||
node.asExpr() = fileNameExtraction(mc) or | ||
node.asExpr() = stringCheck(mc)) | ||
} | ||
} | ||
|
||
|
||
from ZipSlipTaintTrackingConfiguration zipTaintTracking, DataFlow::Node source, DataFlow::Node sink | ||
from TaintTrackingConfiguration zipTaintTracking, DataFlow::Node source, DataFlow::Node sink | ||
where zipTaintTracking.hasFlow(source, sink) | ||
select sink, "Make sure to sanitize relative archive item path before creating path for file extraction if the source of $@ is untrusted", source, "zip archive" | ||
select sink, "Unsanitized zip archive $@, which may contain '..', is used in a file system operation.", source, "item path" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
using System.IO; | ||
using System.IO.Compression; | ||
|
||
class Bad | ||
{ | ||
public static void WriteToDirectory(ZipArchiveEntry entry, | ||
string destDirectory) | ||
{ | ||
string destFileName = Path.Combine(destDirectory, entry.FullName); | ||
entry.ExtractToFile(destFileName); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
using System.IO; | ||
using System.IO.Compression; | ||
|
||
class Good | ||
{ | ||
public static void WriteToDirectory(ZipArchiveEntry entry, | ||
string destDirectory) | ||
{ | ||
string destFileName = Path.GetFullPath(Path.Combine(destDirectory, entry.FullName)); | ||
string fullDestDirPath = Path.GetFullPath(destDirectory + Path.DirectorySeparatorChar); | ||
if (!destFileName.StartsWith(fullDestDirPath)) { | ||
throw new System.InvalidOperationException("Entry is outside the target dir: " + | ||
destFileName); | ||
} | ||
entry.ExtractToFile(destFileName); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
/** | ||
* Provides a taint tracking configuration for reasoning about unsafe zip extraction. | ||
*/ | ||
import csharp | ||
|
||
module ZipSlip { | ||
/** | ||
* A data flow source for unsafe zip extraction. | ||
*/ | ||
abstract class Source extends DataFlow::Node { } | ||
|
||
/** | ||
* A data flow sink for unsafe zip extraction. | ||
*/ | ||
abstract class Sink extends DataFlow::ExprNode { } | ||
|
||
/** | ||
* A sanitizer for unsafe zip extraction. | ||
*/ | ||
abstract class Sanitizer extends DataFlow::ExprNode { } | ||
|
||
/** A taint tracking configuration for Zip Slip */ | ||
class TaintTrackingConfiguration extends TaintTracking::Configuration { | ||
TaintTrackingConfiguration() { | ||
this = "ZipSlipTaintTracking" | ||
} | ||
|
||
override predicate isSource(DataFlow::Node source) { | ||
source instanceof Source | ||
} | ||
|
||
override predicate isSink(DataFlow::Node sink) { | ||
sink instanceof Sink | ||
} | ||
|
||
override predicate isSanitizer(DataFlow::Node node) { | ||
node instanceof Sanitizer | ||
} | ||
} | ||
|
||
/** An access to the `FullName` property of a `ZipArchiveEntry`. */ | ||
class ArchiveFullNameSource extends Source { | ||
ArchiveFullNameSource() { | ||
exists(PropertyAccess pa | | ||
this.asExpr() = pa | | ||
pa.getTarget().getDeclaringType().hasQualifiedName("System.IO.Compression.ZipArchiveEntry") and | ||
pa.getTarget().getName() = "FullName" | ||
) | ||
} | ||
} | ||
|
||
/** An argument to the `ExtractToFile` extension method. */ | ||
class ExtractToFileArgSink extends Sink { | ||
ExtractToFileArgSink() { | ||
exists(MethodCall mc | | ||
mc.getTarget().hasQualifiedName("System.IO.Compression.ZipFileExtensions", "ExtractToFile") and | ||
this.asExpr() = mc.getArgumentForName("destinationFileName") | ||
) | ||
} | ||
} | ||
|
||
/** A path argument to a `File.Open`, `File.OpenWrite`, or `File.Create` method call. */ | ||
class FileOpenArgSink extends Sink { | ||
FileOpenArgSink() { | ||
exists(MethodCall mc | | ||
mc.getTarget().hasQualifiedName("System.IO.File", "Open") or | ||
mc.getTarget().hasQualifiedName("System.IO.File", "OpenWrite") or | ||
mc.getTarget().hasQualifiedName("System.IO.File", "Create") | | ||
this.asExpr() = mc.getArgumentForName("path") | ||
) | ||
} | ||
} | ||
|
||
/** A path argument to a call to the `FileStream` constructor. */ | ||
class FileStreamArgSink extends Sink { | ||
FileStreamArgSink() { | ||
exists(ObjectCreation oc | | ||
oc.getTarget().getDeclaringType().hasQualifiedName("System.IO.FileStream") | | ||
this.asExpr() = oc.getArgumentForName("path") | ||
) | ||
} | ||
} | ||
|
||
/** | ||
* A path argument to a call to the `FileStream` constructor. | ||
* | ||
* This constructor can accept a tainted file name and subsequently be used to open a file stream. | ||
*/ | ||
class FileInfoArgSink extends Sink { | ||
FileInfoArgSink() { | ||
exists(ObjectCreation oc | | ||
oc.getTarget().getDeclaringType().hasQualifiedName("System.IO.FileInfo") | | ||
this.asExpr() = oc.getArgumentForName("fileName") | ||
) | ||
} | ||
} | ||
|
||
/** | ||
* An argument to `GetFileName`. | ||
* | ||
* This is considered a sanitizer because it extracts just the file name, not the full path. | ||
*/ | ||
class GetFileNameSanitizer extends Sanitizer { | ||
GetFileNameSanitizer() { | ||
exists(MethodCall mc | | ||
mc.getTarget().hasQualifiedName("System.IO.Path", "GetFileName") | | ||
this.asExpr() = mc.getAnArgument() | ||
) | ||
} | ||
} | ||
|
||
/** | ||
* A qualifier in a call to `StartsWith` or `Substring` string method. | ||
* | ||
* A call to a String method such as `StartsWith` or `Substring` can indicate a check for a | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You've capitalized "String method" here, but not in the previous sentence. I assume they should be the same? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes. As above, I've actually changed this in a follow up PR, so I'll leave it for now. |
||
* relative path, or a check against the destination folder for whitelisted/target path, etc. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "... for a whitelisted or target path"? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've clarified this in the next PR in the sequence. |
||
*/ | ||
class StringCheckSanitizer extends Sanitizer { | ||
StringCheckSanitizer() { | ||
exists(MethodCall mc | | ||
mc.getTarget().hasQualifiedName("System.String", "StartsWith") or | ||
mc.getTarget().hasQualifiedName("System.String", "Substring") | | ||
this.asExpr() = mc.getQualifier() | ||
) | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,7 @@ | ||
| ZipSlip.cs:24:41:24:52 | access to local variable destFileName | Make sure to sanitize relative archive item path before creating path for file extraction if the source of $@ is untrusted | ZipSlip.cs:19:31:19:44 | access to property FullName | zip archive | | ||
| ZipSlip.cs:32:41:32:52 | access to local variable destFilePath | Make sure to sanitize relative archive item path before creating path for file extraction if the source of $@ is untrusted | ZipSlip.cs:16:52:16:65 | access to property FullName | zip archive | | ||
| ZipSlip.cs:61:74:61:85 | access to local variable destFilePath | Make sure to sanitize relative archive item path before creating path for file extraction if the source of $@ is untrusted | ZipSlip.cs:54:72:54:85 | access to property FullName | zip archive | | ||
| ZipSlip.cs:68:71:68:82 | access to local variable destFilePath | Make sure to sanitize relative archive item path before creating path for file extraction if the source of $@ is untrusted | ZipSlip.cs:54:72:54:85 | access to property FullName | zip archive | | ||
| ZipSlip.cs:75:57:75:68 | access to local variable destFilePath | Make sure to sanitize relative archive item path before creating path for file extraction if the source of $@ is untrusted | ZipSlip.cs:54:72:54:85 | access to property FullName | zip archive | | ||
| ZipSlip.cs:83:58:83:69 | access to local variable destFilePath | Make sure to sanitize relative archive item path before creating path for file extraction if the source of $@ is untrusted | ZipSlip.cs:54:72:54:85 | access to property FullName | zip archive | | ||
| ZipSlip.cs:24:41:24:52 | access to local variable destFileName | Unsanitized zip archive $@, which may contain '..', is used in a file system operation. | ZipSlip.cs:19:31:19:44 | access to property FullName | item path | | ||
| ZipSlip.cs:32:41:32:52 | access to local variable destFilePath | Unsanitized zip archive $@, which may contain '..', is used in a file system operation. | ZipSlip.cs:16:52:16:65 | access to property FullName | item path | | ||
| ZipSlip.cs:61:74:61:85 | access to local variable destFilePath | Unsanitized zip archive $@, which may contain '..', is used in a file system operation. | ZipSlip.cs:54:72:54:85 | access to property FullName | item path | | ||
| ZipSlip.cs:68:71:68:82 | access to local variable destFilePath | Unsanitized zip archive $@, which may contain '..', is used in a file system operation. | ZipSlip.cs:54:72:54:85 | access to property FullName | item path | | ||
| ZipSlip.cs:75:57:75:68 | access to local variable destFilePath | Unsanitized zip archive $@, which may contain '..', is used in a file system operation. | ZipSlip.cs:54:72:54:85 | access to property FullName | item path | | ||
| ZipSlip.cs:83:58:83:69 | access to local variable destFilePath | Unsanitized zip archive $@, which may contain '..', is used in a file system operation. | ZipSlip.cs:54:72:54:85 | access to property FullName | item path | | ||
| ZipSlipBad.cs:10:29:10:40 | access to local variable destFileName | Unsanitized zip archive $@, which may contain '..', is used in a file system operation. | ZipSlipBad.cs:9:59:9:72 | access to property FullName | item path | |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
using System.IO; | ||
using System.IO.Compression; | ||
|
||
class Bad | ||
{ | ||
public static void WriteToDirectory(ZipArchiveEntry entry, | ||
string destDirectory) | ||
{ | ||
string destFileName = Path.Combine(destDirectory, entry.FullName); | ||
entry.ExtractToFile(destFileName); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
using System.IO; | ||
using System.IO.Compression; | ||
|
||
class Good | ||
{ | ||
public static void WriteToDirectory(ZipArchiveEntry entry, | ||
string destDirectory) | ||
{ | ||
string destFileName = Path.GetFullPath(Path.Combine(destDirectory, entry.FullName)); | ||
string fullDestDirPath = Path.GetFullPath(destDirectory + Path.DirectorySeparatorChar); | ||
if (!destFileName.StartsWith(fullDestDirPath)) { | ||
throw new System.InvalidOperationException("Entry is outside the target dir: " + | ||
destFileName); | ||
} | ||
entry.ExtractToFile(destFileName); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps "a call to a ..."?
(or "a call to the ...")
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll leave this for now - I have a follow up PR that splits this class and modifies the documentation.