Repository navigation
Python: Unsafe unpacking using shutil.unpack_archive() query and tests
#11570
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
Merged
Merged
Changes from all commits
Commits
Show all changes
54 commits
Select commit
Hold shift + click to select a range
68fd75c
UnpackUnsafe query and tests
Sim4n6 054c06b
Update UnsafeUnpack.ql
Sim4n6 a5849eb
Improved the additional taint step using InstanceSource
Sim4n6 c22c0b5
Update python/ql/src/experimental/Security/CWE-022bis/UnsafeUnpack.qhelp
Sim4n6 9a60202
Update python/ql/src/experimental/Security/CWE-022bis/UnsafeUnpack.qhelp
Sim4n6 58570b4
Update python/ql/src/experimental/Security/CWE-022bis/UnsafeUnpack.ql
Sim4n6 4896e62
Use of more generic terms
Sim4n6 2801b84
A fix of the tag name
Sim4n6 9336f4f
Considering the use of contextlib.closing() method
Sim4n6 545aab0
tarball path provided using CLI argument (source)
Sim4n6 eff1325
Copying the response data to the archive
Sim4n6 b194524
read by chunks as additional step
Sim4n6 2f68b54
A simple download_file() call from maybe boto3
Sim4n6 54109b8
Add source wget.download
Sim4n6 4376870
An uploded file is considered a source
Sim4n6 b5a6f6e
Merge pull request #1 from github/main
Sim4n6 10d6ebf
Use of inline tests for dataflow queries
Sim4n6 0ed4808
Update python/ql/src/experimental/Security/CWE-022bis/UnsafeUnpack.ql
Sim4n6 2d38993
Add a missing "and"
Sim4n6 22af6f5
Restrict download_file() to boto3 lib
Sim4n6 9b5b0c6
Handle the download of a tarball using wget pkg.
Sim4n6 f867c90
Commit the expected results
Sim4n6 2e4cb63
Optimize the Argparse filename as a source.
Sim4n6 9464940
Add expected results for argparse source
Sim4n6 aaa0040
Seperate the dataflow config from the query
Sim4n6 54cc4d6
Opt for any source from RemoteFlowSource.
Sim4n6 51b11de
Add a Django Upload examples
Sim4n6 1a21148
Restrain the source and add two steps.
Sim4n6 998f1bf
Some reformatting
Sim4n6 bca053f
Move the config query to the parent directory
Sim4n6 5f0bf10
Update the dataflow test query and the expected results
Sim4n6 e410424
Update the import relative to the dataflow config
Sim4n6 18d8bbc
Updated the expected results accordingly
Sim4n6 207ed3d
Constrain the object & the call
Sim4n6 8ef2aa0
Update python/ql/src/experimental/Security/UnsafeUnpackQuery.qll
Sim4n6 7a126a2
Merge branch 'github:main' into UnsafeUnpack
Sim4n6 0b27b13
Update python/ql/src/experimental/Security/UnsafeUnpackQuery.qll
Sim4n6 ee21312
Add builtin open as an additional step
Sim4n6 0e2f378
Organize steps to correspond to the sample code
Sim4n6 a4aaf0e
Remove a write step & update the builtin open step
Sim4n6 0707064
Constrain the save/path step
Sim4n6 7079def
Add an S3 source with Session or download_fileobj
Sim4n6 1a8c9ab
Incorporate Sink & Source as steps from TarSlipQry
Sim4n6 a015084
Updated the expected test file
Sim4n6 ec82d61
Add another frequently used step
Sim4n6 9e28502
Comment modif + remove redundant cast
Sim4n6 4196230
use if-then-else rather than nested exists
Sim4n6 16ef504
Update python/ql/src/experimental/Security/UnsafeUnpackQuery.qll
Sim4n6 b04d568
add a blank line at the end of the file
Sim4n6 09df055
Fix the exists cast warning
Sim4n6 eed19a3
Fix autoformatting issues
Sim4n6 80d4fb5
Organisation TarSlip/UnsafeUnpack into two folders
Sim4n6 518684b
Put back the annotation result=BAD
Sim4n6 d7af801
Fail tests when missing annotation on sink orfail
Sim4n6 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
56 changes: 56 additions & 0 deletions
56
python/ql/src/experimental/Security/CWE-022bis/UnsafeUnpack.qhelp
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| <!DOCTYPE qhelp PUBLIC | ||
| "-//Semmle//qhelp//EN" | ||
| "qhelp.dtd"> | ||
| <qhelp> | ||
|
|
||
| <overview> | ||
| <p>Extracting files from a malicious tarball without validating that the destination file path | ||
| is within the destination directory using <code>shutil.unpack_archive()</code> can cause files outside the | ||
| destination directory to be overwritten, due to the possible presence of directory traversal elements | ||
| (<code>..</code>) in archive path names.</p> | ||
|
|
||
| <p>Tarball 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 tarball contains a file entry <code>../sneaky-file.txt</code>, and the tarball | ||
| is extracted to the directory <code>/tmp/tmp123</code>, then naively combining the paths would result | ||
| in an output file path of <code>/tmp/tmp123/../sneaky-file.txt</code>, which would cause the file to be | ||
| written to <code>/tmp/</code>.</p> | ||
|
|
||
| </overview> | ||
| <recommendation> | ||
|
|
||
| <p>Ensure that output paths constructed from tarball entries are validated | ||
| to prevent writing files to unexpected locations.</p> | ||
|
|
||
| <p>Consider using a safer module, such as: <code>zipfile</code></p> | ||
|
|
||
| </recommendation> | ||
|
|
||
| <example> | ||
| <p> | ||
| In this example an archive is extracted without validating file paths. | ||
| </p> | ||
|
|
||
| <sample src="examples/HIT_UnsafeUnpack.py" /> | ||
|
|
||
| <p>To fix this vulnerability, we need to call the function <code>tarfile.extract()</code> | ||
| on each <code>member</code> after verifying that it does not contain either <code>..</code> or startswith <code>/</code>. | ||
| </p> | ||
|
|
||
| <sample src="examples/NoHIT_UnsafeUnpack.py" /> | ||
|
|
||
| </example> | ||
| <references> | ||
|
|
||
| <li> | ||
| Shutil official documentation | ||
| <a href="https://docs.python.org/3/library/shutil.html?highlight=unpack_archive#shutil.unpack_archive">shutil.unpack_archive() warning.</a> | ||
| </li> | ||
| </references> | ||
| </qhelp> |
24 changes: 24 additions & 0 deletions
24
python/ql/src/experimental/Security/CWE-022bis/UnsafeUnpack.ql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| /** | ||
| * @name Arbitrary file write during a tarball extraction from a user controlled source | ||
| * @description Extracting files from a potentially malicious tarball using `shutil.unpack_archive()` without validating | ||
| * that the destination file path is within the destination directory can cause files outside | ||
| * the destination directory to be overwritten. More precisely, if the tarball comes from a user controlled | ||
| * location either a remote one or cli argument. | ||
| * @kind path-problem | ||
| * @id py/unsafe-unpacking | ||
| * @problem.severity error | ||
| * @security-severity 7.5 | ||
| * @precision high | ||
| * @tags security | ||
| * experimental | ||
| * external/cwe/cwe-022 | ||
| */ | ||
|
|
||
| import python | ||
| import experimental.Security.UnsafeUnpackQuery | ||
| import DataFlow::PathGraph | ||
|
|
||
| from UnsafeUnpackingConfig config, DataFlow::PathNode source, DataFlow::PathNode sink | ||
| where config.hasFlowPath(source, sink) | ||
| select sink.getNode(), source, sink, | ||
| "Unsafe extraction from a malicious tarball retrieved from a remote location." |
12 changes: 12 additions & 0 deletions
12
python/ql/src/experimental/Security/CWE-022bis/examples/HIT_UnsafeUnpack.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import requests | ||
| import shutil | ||
|
|
||
| url = "https://www.someremote.location/tarball.tar.gz" | ||
| response = requests.get(url, stream=True) | ||
|
|
||
| tarpath = "/tmp/tmp456/tarball.tar.gz" | ||
| with open(tarpath, "wb") as f: | ||
| f.write(response.raw.read()) | ||
|
|
||
| untarredpath = "/tmp/tmp123" | ||
| shutil.unpack_archive(tarpath, untarredpath) |
17 changes: 17 additions & 0 deletions
17
python/ql/src/experimental/Security/CWE-022bis/examples/NoHIT_UnsafeUnpack.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import requests | ||
| import tarfile | ||
|
|
||
| url = "https://www.someremote.location/tarball.tar.gz" | ||
| response = requests.get(url, stream=True) | ||
|
|
||
| tarpath = "/tmp/tmp456/tarball.tar.gz" | ||
| with open(tarpath, "wb") as f: | ||
| f.write(response.raw.read()) | ||
|
|
||
| untarredpath = "/tmp/tmp123" | ||
| with tarfile.open(tarpath) as tar: | ||
| for member in tar.getmembers(): | ||
| if member.name.startswith("/") or ".." in member.name: | ||
| raise Exception("Path traversal identified in tarball") | ||
|
|
||
| tar.extract(untarredpath, member) |
213 changes: 213 additions & 0 deletions
213
python/ql/src/experimental/Security/UnsafeUnpackQuery.qll
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,213 @@ | ||
| /** | ||
| * Provides a taint-tracking configuration for detecting "UnsafeUnpacking" vulnerabilities. | ||
| */ | ||
|
|
||
| import python | ||
| import semmle.python.Concepts | ||
| import semmle.python.dataflow.new.internal.DataFlowPublic | ||
| import semmle.python.ApiGraphs | ||
| import semmle.python.dataflow.new.TaintTracking | ||
| import semmle.python.frameworks.Stdlib | ||
| import semmle.python.dataflow.new.RemoteFlowSources | ||
|
|
||
| /** | ||
| * Handle those three cases of Tarfile opens: | ||
| * - `tarfile.open()` | ||
| * - `tarfile.TarFile()` | ||
| * - `MKtarfile.Tarfile.open()` | ||
| */ | ||
| API::Node tarfileOpen() { | ||
| result in [ | ||
| API::moduleImport("tarfile").getMember(["open", "TarFile"]), | ||
| API::moduleImport("tarfile").getMember("TarFile").getASubclass().getMember("open") | ||
| ] | ||
| } | ||
|
|
||
| /** | ||
| * A class for handling the previous three cases, plus the use of `closing` in with the previous cases | ||
| */ | ||
| class AllTarfileOpens extends API::CallNode { | ||
| AllTarfileOpens() { | ||
| this = tarfileOpen().getACall() | ||
| or | ||
| exists(API::Node closing, Node arg | | ||
| closing = API::moduleImport("contextlib").getMember("closing") and | ||
| this = closing.getACall() and | ||
| arg = this.getArg(0) and | ||
| arg = tarfileOpen().getACall() | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| class UnsafeUnpackingConfig extends TaintTracking::Configuration { | ||
| UnsafeUnpackingConfig() { this = "UnsafeUnpackingConfig" } | ||
|
|
||
| override predicate isSource(DataFlow::Node source) { | ||
| // A source coming from a remote location | ||
| source instanceof RemoteFlowSource | ||
| or | ||
| // A source coming from a CLI argparse module | ||
| // see argparse: https://docs.python.org/3/library/argparse.html | ||
| exists(MethodCallNode args | | ||
| args = source.(AttrRead).getObject().getALocalSource() and | ||
| args = | ||
| API::moduleImport("argparse") | ||
| .getMember("ArgumentParser") | ||
| .getReturn() | ||
| .getMember("parse_args") | ||
| .getACall() | ||
| ) | ||
| or | ||
| // A source catching an S3 file download | ||
| // see boto3: https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Client.download_file | ||
| source = | ||
| API::moduleImport("boto3") | ||
| .getMember("client") | ||
| .getReturn() | ||
| .getMember(["download_file", "download_fileobj"]) | ||
| .getACall() | ||
| .getArg(2) | ||
| or | ||
| // A source catching an S3 file download | ||
| // see boto3: https://boto3.amazonaws.com/v1/documentation/api/latest/reference/core/session.html | ||
| source = | ||
| API::moduleImport("boto3") | ||
| .getMember("Session") | ||
| .getReturn() | ||
| .getMember("client") | ||
| .getReturn() | ||
| .getMember(["download_file", "download_fileobj"]) | ||
| .getACall() | ||
| .getArg(2) | ||
| or | ||
| // A source download a file using wget | ||
| // see wget: https://pypi.org/project/wget/ | ||
| exists(API::CallNode mcn | | ||
| mcn = API::moduleImport("wget").getMember("download").getACall() and | ||
| if exists(mcn.getArg(1)) then source = mcn.getArg(1) else source = mcn.getReturn().asSource() | ||
| ) | ||
| or | ||
| // catch the Django uploaded files as a source | ||
| // see HttpRequest.FILES: https://docs.djangoproject.com/en/4.1/ref/request-response/#django.http.HttpRequest.FILES | ||
| source.(AttrRead).getAttributeName() = "FILES" | ||
| } | ||
|
|
||
| override predicate isSink(DataFlow::Node sink) { | ||
| ( | ||
| // A sink capturing method calls to `unpack_archive`. | ||
| sink = API::moduleImport("shutil").getMember("unpack_archive").getACall().getArg(0) | ||
| or | ||
| // A sink capturing method calls to `extractall` without `members` argument. | ||
| // For a call to `file.extractall` without `members` argument, `file` is considered a sink. | ||
| exists(MethodCallNode call, AllTarfileOpens atfo | | ||
| call = atfo.getReturn().getMember("extractall").getACall() and | ||
| not exists(call.getArgByName("members")) and | ||
| sink = call.getObject() | ||
| ) | ||
| or | ||
| // A sink capturing method calls to `extractall` with `members` argument. | ||
| // For a call to `file.extractall` with `members` argument, `file` is considered a sink if not | ||
| // a the `members` argument contains a NameConstant as None, a List or call to the method `getmembers`. | ||
| // Otherwise, the argument of `members` is considered a sink. | ||
| exists(MethodCallNode call, Node arg, AllTarfileOpens atfo | | ||
| call = atfo.getReturn().getMember("extractall").getACall() and | ||
| arg = call.getArgByName("members") and | ||
| if | ||
| arg.asCfgNode() instanceof NameConstantNode or | ||
| arg.asCfgNode() instanceof ListNode | ||
| then sink = call.getObject() | ||
| else | ||
| if arg.(MethodCallNode).getMethodName() = "getmembers" | ||
| then sink = arg.(MethodCallNode).getObject() | ||
| else sink = call.getArgByName("members") | ||
| ) | ||
| or | ||
| // An argument to `extract` is considered a sink. | ||
| exists(AllTarfileOpens atfo | | ||
| sink = atfo.getReturn().getMember("extract").getACall().getArg(0) | ||
| ) | ||
| or | ||
| //An argument to `_extract_member` is considered a sink. | ||
| exists(MethodCallNode call, AllTarfileOpens atfo | | ||
| call = atfo.getReturn().getMember("_extract_member").getACall() and | ||
| call.getArg(1).(AttrRead).accesses(sink, "name") | ||
| ) | ||
| ) and | ||
| not sink.getScope().getLocation().getFile().inStdlib() | ||
| } | ||
|
|
||
| override predicate isAdditionalTaintStep(DataFlow::Node nodeFrom, DataFlow::Node nodeTo) { | ||
| // Reading the response | ||
| nodeTo.(MethodCallNode).calls(nodeFrom, "read") | ||
| or | ||
| // Open a file for access | ||
| exists(MethodCallNode cn | | ||
| cn.calls(nodeTo, "open") and | ||
| cn.flowsTo(nodeFrom) | ||
| ) | ||
|
Sim4n6 marked this conversation as resolved.
|
||
| or | ||
| // Open a file for access using builtin | ||
| exists(API::CallNode cn | | ||
| cn = API::builtin("open").getACall() and | ||
| nodeTo = cn.getArg(0) and | ||
| cn.flowsTo(nodeFrom) | ||
| ) | ||
| or | ||
| // Write access | ||
| exists(MethodCallNode cn | | ||
| cn.calls(nodeTo, "write") and | ||
| nodeFrom = cn.getArg(0) | ||
| ) | ||
| or | ||
| // Retrieve Django uploaded files | ||
| // see getlist(): https://docs.djangoproject.com/en/4.1/ref/request-response/#django.http.QueryDict.getlist | ||
| // see chunks(): https://docs.djangoproject.com/en/4.1/ref/files/uploads/#django.core.files.uploadedfile.UploadedFile.chunks | ||
| nodeTo.(MethodCallNode).calls(nodeFrom, ["getlist", "get", "chunks"]) | ||
| or | ||
| // Considering the use of "fs" | ||
| // see fs: https://docs.djangoproject.com/en/4.1/ref/files/storage/#the-filesystemstorage-class | ||
| nodeTo = | ||
| API::moduleImport("django") | ||
| .getMember("core") | ||
| .getMember("files") | ||
| .getMember("storage") | ||
| .getMember("FileSystemStorage") | ||
| .getReturn() | ||
| .getMember(["save", "path"]) | ||
| .getACall() and | ||
| nodeFrom = nodeTo.(MethodCallNode).getArg(0) | ||
| or | ||
| // Accessing the name or raw content | ||
| nodeTo.(AttrRead).accesses(nodeFrom, ["name", "raw"]) | ||
| or | ||
| // Join the base_dir to the filename | ||
| nodeTo = API::moduleImport("os").getMember("path").getMember("join").getACall() and | ||
| nodeFrom = nodeTo.(API::CallNode).getArg(1) | ||
| or | ||
| // Go through an Open for a Tarfile | ||
| nodeTo = tarfileOpen().getACall() and nodeFrom = nodeTo.(MethodCallNode).getArg(0) | ||
| or | ||
| // Handle the case where the getmembers is used. | ||
| nodeTo.(MethodCallNode).calls(nodeFrom, "getmembers") and | ||
| nodeFrom instanceof AllTarfileOpens | ||
| or | ||
| // To handle the case of `with closing(tarfile.open()) as file:` | ||
| // we add a step from the first argument of `closing` to the call to `closing`, | ||
| // whenever that first argument is a return of `tarfile.open()`. | ||
| nodeTo = API::moduleImport("contextlib").getMember("closing").getACall() and | ||
| nodeFrom = nodeTo.(API::CallNode).getArg(0) and | ||
| nodeFrom = tarfileOpen().getReturn().getAValueReachableFromSource() | ||
| or | ||
| // see Path : https://docs.python.org/3/library/pathlib.html#pathlib.Path | ||
| nodeTo = API::moduleImport("pathlib").getMember("Path").getACall() and | ||
| nodeFrom = nodeTo.(API::CallNode).getArg(0) | ||
| or | ||
| // Use of absolutepath | ||
| // see absolute : https://docs.python.org/3/library/pathlib.html#pathlib.Path.absolute | ||
| exists(API::CallNode mcn | | ||
| mcn = API::moduleImport("pathlib").getMember("Path").getACall() and | ||
| nodeTo = mcn.getAMethodCall("absolute") and | ||
| nodeFrom = mcn.getArg(0) | ||
| ) | ||
| } | ||
| } | ||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
2 changes: 2 additions & 0 deletions
2
...test/experimental/query-tests/Security/CWE-022-UnsafeUnpacking/DataflowQueryTest.expected
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| missingAnnotationOnSink | ||
| failures |
3 changes: 3 additions & 0 deletions
3
...on/ql/test/experimental/query-tests/Security/CWE-022-UnsafeUnpacking/DataflowQueryTest.ql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| import python | ||
| import experimental.dataflow.TestUtil.DataflowQueryTest | ||
| import experimental.Security.UnsafeUnpackQuery |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.