Skip to content
Merged
Show file tree
Hide file tree
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 Dec 5, 2022
054c06b
Update UnsafeUnpack.ql
Sim4n6 Dec 6, 2022
a5849eb
Improved the additional taint step using InstanceSource
Sim4n6 Dec 6, 2022
c22c0b5
Update python/ql/src/experimental/Security/CWE-022bis/UnsafeUnpack.qhelp
Sim4n6 Dec 6, 2022
9a60202
Update python/ql/src/experimental/Security/CWE-022bis/UnsafeUnpack.qhelp
Sim4n6 Dec 6, 2022
58570b4
Update python/ql/src/experimental/Security/CWE-022bis/UnsafeUnpack.ql
Sim4n6 Dec 6, 2022
4896e62
Use of more generic terms
Sim4n6 Dec 6, 2022
2801b84
A fix of the tag name
Sim4n6 Dec 6, 2022
9336f4f
Considering the use of contextlib.closing() method
Sim4n6 Dec 8, 2022
545aab0
tarball path provided using CLI argument (source)
Sim4n6 Dec 9, 2022
eff1325
Copying the response data to the archive
Sim4n6 Dec 10, 2022
b194524
read by chunks as additional step
Sim4n6 Dec 10, 2022
2f68b54
A simple download_file() call from maybe boto3
Sim4n6 Dec 12, 2022
54109b8
Add source wget.download
Sim4n6 Dec 13, 2022
4376870
An uploded file is considered a source
Sim4n6 Dec 15, 2022
b5a6f6e
Merge pull request #1 from github/main
Sim4n6 Jan 25, 2023
10d6ebf
Use of inline tests for dataflow queries
Sim4n6 Jan 25, 2023
0ed4808
Update python/ql/src/experimental/Security/CWE-022bis/UnsafeUnpack.ql
Sim4n6 Jan 25, 2023
2d38993
Add a missing "and"
Sim4n6 Jan 25, 2023
22af6f5
Restrict download_file() to boto3 lib
Sim4n6 Jan 25, 2023
9b5b0c6
Handle the download of a tarball using wget pkg.
Sim4n6 Jan 25, 2023
f867c90
Commit the expected results
Sim4n6 Jan 25, 2023
2e4cb63
Optimize the Argparse filename as a source.
Sim4n6 Jan 26, 2023
9464940
Add expected results for argparse source
Sim4n6 Jan 26, 2023
aaa0040
Seperate the dataflow config from the query
Sim4n6 Jan 26, 2023
54cc4d6
Opt for any source from RemoteFlowSource.
Sim4n6 Jan 26, 2023
51b11de
Add a Django Upload examples
Sim4n6 Jan 26, 2023
1a21148
Restrain the source and add two steps.
Sim4n6 Jan 26, 2023
998f1bf
Some reformatting
Sim4n6 Jan 26, 2023
bca053f
Move the config query to the parent directory
Sim4n6 Jan 27, 2023
5f0bf10
Update the dataflow test query and the expected results
Sim4n6 Jan 27, 2023
e410424
Update the import relative to the dataflow config
Sim4n6 Jan 27, 2023
18d8bbc
Updated the expected results accordingly
Sim4n6 Jan 27, 2023
207ed3d
Constrain the object & the call
Sim4n6 Jan 27, 2023
8ef2aa0
Update python/ql/src/experimental/Security/UnsafeUnpackQuery.qll
Sim4n6 Jan 27, 2023
7a126a2
Merge branch 'github:main' into UnsafeUnpack
Sim4n6 Jan 27, 2023
0b27b13
Update python/ql/src/experimental/Security/UnsafeUnpackQuery.qll
Sim4n6 Jan 27, 2023
ee21312
Add builtin open as an additional step
Sim4n6 Jan 27, 2023
0e2f378
Organize steps to correspond to the sample code
Sim4n6 Jan 27, 2023
a4aaf0e
Remove a write step & update the builtin open step
Sim4n6 Jan 28, 2023
0707064
Constrain the save/path step
Sim4n6 Jan 28, 2023
7079def
Add an S3 source with Session or download_fileobj
Sim4n6 Jan 29, 2023
1a8c9ab
Incorporate Sink & Source as steps from TarSlipQry
Sim4n6 Feb 2, 2023
a015084
Updated the expected test file
Sim4n6 Feb 2, 2023
ec82d61
Add another frequently used step
Sim4n6 Feb 5, 2023
9e28502
Comment modif + remove redundant cast
Sim4n6 Feb 8, 2023
4196230
use if-then-else rather than nested exists
Sim4n6 Feb 8, 2023
16ef504
Update python/ql/src/experimental/Security/UnsafeUnpackQuery.qll
Sim4n6 Feb 9, 2023
b04d568
add a blank line at the end of the file
Sim4n6 Feb 9, 2023
09df055
Fix the exists cast warning
Sim4n6 Feb 9, 2023
eed19a3
Fix autoformatting issues
Sim4n6 Feb 10, 2023
80d4fb5
Organisation TarSlip/UnsafeUnpack into two folders
Sim4n6 Feb 12, 2023
518684b
Put back the annotation result=BAD
Sim4n6 Feb 12, 2023
d7af801
Fail tests when missing annotation on sink orfail
Sim4n6 Feb 12, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 python/ql/src/experimental/Security/CWE-022bis/UnsafeUnpack.ql
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."
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)
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 python/ql/src/experimental/Security/UnsafeUnpackQuery.qll
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()`
Comment thread
Sim4n6 marked this conversation as resolved.
*/
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)
)
Comment thread
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)
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
missingAnnotationOnSink
failures
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
Loading