Skip to content

Commit 795a1c7

Browse files
authored
Merge pull request github#5443 from jorgectf/jorgectf/python/ldapInjection
Python: Add LDAP Injection query
2 parents 2fd461e + f807c2f commit 795a1c7

File tree

16 files changed

+703
-0
lines changed

16 files changed

+703
-0
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<!DOCTYPE qhelp PUBLIC
2+
"-//Semmle//qhelp//EN"
3+
"qhelp.dtd">
4+
<qhelp>
5+
<overview>
6+
<p>If an LDAP query or DN is built using string concatenation or string formatting, and the
7+
components of the concatenation include user input without any proper sanitization, a user
8+
is likely to be able to run malicious LDAP queries.</p>
9+
</overview>
10+
11+
<recommendation>
12+
<p>If user input must be included in an LDAP query or DN, it should be escaped to
13+
avoid a malicious user providing special characters that change the meaning
14+
of the query. In Python2, user input should be escaped with <code>ldap.dn.escape_dn_chars</code>
15+
or <code>ldap.filter.escape_filter_chars</code>, while in Python3, user input should be escaped with
16+
<code>ldap3.utils.dn.escape_rdn</code> or <code>ldap3.utils.conv.escape_filter_chars</code>
17+
depending on the component tainted by the user. A good practice is to escape filter characters
18+
that could change the meaning of the query (https://tools.ietf.org/search/rfc4515#section-3).</p>
19+
</recommendation>
20+
21+
<example>
22+
<p>In the following examples, the code accepts both <code>username</code> and <code>dc</code> from the user,
23+
which it then uses to build a LDAP query and DN.</p>
24+
25+
<p>The first and the second example uses the unsanitized user input directly
26+
in the search filter and DN for the LDAP query.
27+
A malicious user could provide special characters to change the meaning of these
28+
components, and search for a completely different set of values.</p>
29+
30+
<sample src="examples/example_bad1.py" />
31+
<sample src="examples/example_bad2.py" />
32+
33+
<p>In the third and four example, the input provided by the user is sanitized before it is included in the search filter or DN.
34+
This ensures the meaning of the query cannot be changed by a malicious user.</p>
35+
36+
<sample src="examples/example_good1.py" />
37+
<sample src="examples/example_good2.py" />
38+
</example>
39+
40+
<references>
41+
<li>OWASP: <a href="https://cheatsheetseries.owasp.org/cheatsheets/LDAP_Injection_Prevention_Cheat_Sheet.html">LDAP Injection Prevention Cheat Sheet</a>.</li>
42+
<li>OWASP: <a href="https://owasp.org/www-community/attacks/LDAP_Injection">LDAP Injection</a>.</li>
43+
<li>SonarSource: <a href="https://rules.sonarsource.com/python/RSPEC-2078">RSPEC-2078</a>.</li>
44+
<li>Python2: <a href="https://www.python-ldap.org/en/python-ldap-3.3.0/reference/ldap.html">LDAP Documentation</a>.</li>
45+
<li>Python3: <a href="https://ldap3.readthedocs.io/en/latest/">LDAP Documentation</a>.</li>
46+
<li>Wikipedia: <a href="https://en.wikipedia.org/wiki/LDAP_injection">LDAP injection</a>.</li>
47+
<li>BlackHat: <a href="https://www.blackhat.com/presentations/bh-europe-08/Alonso-Parada/Whitepaper/bh-eu-08-alonso-parada-WP.pdf">LDAP Injection and Blind LDAP Injection</a>.</li>
48+
<li>LDAP: <a href="https://ldap.com/2018/05/04/understanding-and-defending-against-ldap-injection-attacks/">Understanding and Defending Against LDAP Injection Attacks</a>.</li>
49+
</references>
50+
</qhelp>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* @name LDAP query built from user-controlled sources
3+
* @description Building an LDAP query from user-controlled sources is vulnerable to insertion of
4+
* malicious LDAP code by the user.
5+
* @kind path-problem
6+
* @problem.severity error
7+
* @id py/ldap-injection
8+
* @tags experimental
9+
* security
10+
* external/cwe/cwe-090
11+
*/
12+
13+
// Determine precision above
14+
import python
15+
import experimental.semmle.python.security.injection.LDAP
16+
import DataFlow::PathGraph
17+
18+
from LDAPInjectionFlowConfig config, DataFlow::PathNode source, DataFlow::PathNode sink
19+
where config.hasFlowPath(source, sink)
20+
select sink.getNode(), source, sink, "$@ LDAP query parameter comes from $@.", sink.getNode(),
21+
"This", source.getNode(), "a user-provided value"
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from flask import request, Flask
2+
import ldap
3+
4+
5+
@app.route("/normal")
6+
def normal():
7+
unsafe_dc = request.args['dc']
8+
unsafe_filter = request.args['username']
9+
10+
dn = "dc={}".format(unsafe_dc)
11+
search_filter = "(user={})".format(unsafe_filter)
12+
13+
ldap_connection = ldap.initialize("ldap://127.0.0.1")
14+
user = ldap_connection.search_s(
15+
dn, ldap.SCOPE_SUBTREE, search_filter)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from flask import request, Flask
2+
import ldap3
3+
4+
5+
@app.route("/normal")
6+
def normal():
7+
unsafe_dc = request.args['dc']
8+
unsafe_filter = request.args['username']
9+
10+
dn = "dc={}".format(unsafe_dc)
11+
search_filter = "(user={})".format(unsafe_filter)
12+
13+
srv = ldap3.Server('ldap://127.0.0.1')
14+
conn = ldap3.Connection(srv, user=dn, auto_bind=True)
15+
conn.search(dn, search_filter)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from flask import request, Flask
2+
import ldap
3+
import ldap.filter
4+
import ldap.dn
5+
6+
7+
@app.route("/normal")
8+
def normal():
9+
unsafe_dc = request.args['dc']
10+
unsafe_filter = request.args['username']
11+
12+
safe_dc = ldap.dn.escape_dn_chars(unsafe_dc)
13+
safe_filter = ldap.filter.escape_filter_chars(unsafe_filter)
14+
15+
dn = "dc={}".format(safe_dc)
16+
search_filter = "(user={})".format(safe_filter)
17+
18+
ldap_connection = ldap.initialize("ldap://127.0.0.1")
19+
user = ldap_connection.search_s(
20+
dn, ldap.SCOPE_SUBTREE, search_filter)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from flask import request, Flask
2+
import ldap3
3+
from ldap3.utils.dn import escape_rdn
4+
from ldap3.utils.conv import escape_filter_chars
5+
6+
7+
@app.route("/normal")
8+
def normal():
9+
unsafe_dc = request.args['dc']
10+
unsafe_filter = request.args['username']
11+
12+
safe_dc = escape_rdn(unsafe_dc)
13+
safe_filter = escape_filter_chars(unsafe_filter)
14+
15+
dn = "dc={}".format(safe_dc)
16+
search_filter = "(user={})".format(safe_filter)
17+
18+
srv = ldap3.Server('ldap://127.0.0.1')
19+
conn = ldap3.Connection(srv, user=dn, auto_bind=True)
20+
conn.search(dn, search_filter)

python/ql/src/experimental/semmle/python/Concepts.qll

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,69 @@ class RegexEscape extends DataFlow::Node {
8080

8181
DataFlow::Node getRegexNode() { result = range.getRegexNode() }
8282
}
83+
84+
/** Provides classes for modeling LDAP query execution-related APIs. */
85+
module LDAPQuery {
86+
/**
87+
* A data-flow node that collects methods executing a LDAP query.
88+
*
89+
* Extend this class to model new APIs. If you want to refine existing API models,
90+
* extend `LDAPQuery` instead.
91+
*/
92+
abstract class Range extends DataFlow::Node {
93+
/**
94+
* Gets the argument containing the executed expression.
95+
*/
96+
abstract DataFlow::Node getQuery();
97+
}
98+
}
99+
100+
/**
101+
* A data-flow node that collect methods executing a LDAP query.
102+
*
103+
* Extend this class to refine existing API models. If you want to model new APIs,
104+
* extend `LDAPQuery::Range` instead.
105+
*/
106+
class LDAPQuery extends DataFlow::Node {
107+
LDAPQuery::Range range;
108+
109+
LDAPQuery() { this = range }
110+
111+
/**
112+
* Gets the argument containing the executed expression.
113+
*/
114+
DataFlow::Node getQuery() { result = range.getQuery() }
115+
}
116+
117+
/** Provides classes for modeling LDAP components escape-related APIs. */
118+
module LDAPEscape {
119+
/**
120+
* A data-flow node that collects functions escaping LDAP components.
121+
*
122+
* Extend this class to model new APIs. If you want to refine existing API models,
123+
* extend `LDAPEscape` instead.
124+
*/
125+
abstract class Range extends DataFlow::Node {
126+
/**
127+
* Gets the argument containing the escaped expression.
128+
*/
129+
abstract DataFlow::Node getAnInput();
130+
}
131+
}
132+
133+
/**
134+
* A data-flow node that collects functions escaping LDAP components.
135+
*
136+
* Extend this class to refine existing API models. If you want to model new APIs,
137+
* extend `LDAPEscape::Range` instead.
138+
*/
139+
class LDAPEscape extends DataFlow::Node {
140+
LDAPEscape::Range range;
141+
142+
LDAPEscape() { this = range }
143+
144+
/**
145+
* Gets the argument containing the escaped expression.
146+
*/
147+
DataFlow::Node getAnInput() { result = range.getAnInput() }
148+
}

python/ql/src/experimental/semmle/python/Frameworks.qll

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
*/
44

55
private import experimental.semmle.python.frameworks.Stdlib
6+
private import experimental.semmle.python.frameworks.LDAP
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* Provides classes modeling security-relevant aspects of the LDAP libraries.
3+
*/
4+
5+
private import python
6+
private import semmle.python.dataflow.new.DataFlow
7+
private import semmle.python.dataflow.new.TaintTracking
8+
private import semmle.python.dataflow.new.RemoteFlowSources
9+
private import experimental.semmle.python.Concepts
10+
private import semmle.python.ApiGraphs
11+
12+
/**
13+
* Provides models for Python's ldap-related libraries.
14+
*/
15+
private module LDAP {
16+
/**
17+
* Provides models for the `python-ldap` PyPI package (imported as `ldap`).
18+
*
19+
* See https://www.python-ldap.org/en/python-ldap-3.3.0/index.html
20+
*/
21+
private module LDAP2 {
22+
/**
23+
* List of `ldap` methods used to execute a query.
24+
*
25+
* See https://www.python-ldap.org/en/python-ldap-3.3.0/reference/ldap.html#functions
26+
*/
27+
private class LDAP2QueryMethods extends string {
28+
LDAP2QueryMethods() {
29+
this in ["search", "search_s", "search_st", "search_ext", "search_ext_s"]
30+
}
31+
}
32+
33+
/**
34+
* A class to find `ldap` methods executing a query.
35+
*
36+
* See `LDAP2QueryMethods`
37+
*/
38+
private class LDAP2Query extends DataFlow::CallCfgNode, LDAPQuery::Range {
39+
DataFlow::Node ldapQuery;
40+
41+
LDAP2Query() {
42+
exists(DataFlow::AttrRead searchMethod |
43+
this.getFunction() = searchMethod and
44+
API::moduleImport("ldap").getMember("initialize").getACall() =
45+
searchMethod.getObject().getALocalSource() and
46+
searchMethod.getAttributeName() instanceof LDAP2QueryMethods and
47+
(
48+
ldapQuery = this.getArg(0)
49+
or
50+
(
51+
ldapQuery = this.getArg(2) or
52+
ldapQuery = this.getArgByName("filterstr")
53+
)
54+
)
55+
)
56+
}
57+
58+
override DataFlow::Node getQuery() { result = ldapQuery }
59+
}
60+
61+
/**
62+
* A class to find calls to `ldap.dn.escape_dn_chars`.
63+
*
64+
* See https://github.com/python-ldap/python-ldap/blob/7ce471e238cdd9a4dd8d17baccd1c9e05e6f894a/Lib/ldap/dn.py#L17
65+
*/
66+
private class LDAP2EscapeDNCall extends DataFlow::CallCfgNode, LDAPEscape::Range {
67+
LDAP2EscapeDNCall() {
68+
this = API::moduleImport("ldap").getMember("dn").getMember("escape_dn_chars").getACall()
69+
}
70+
71+
override DataFlow::Node getAnInput() { result = this.getArg(0) }
72+
}
73+
74+
/**
75+
* A class to find calls to `ldap.filter.escape_filter_chars`.
76+
*
77+
* See https://www.python-ldap.org/en/python-ldap-3.3.0/reference/ldap-filter.html#ldap.filter.escape_filter_chars
78+
*/
79+
private class LDAP2EscapeFilterCall extends DataFlow::CallCfgNode, LDAPEscape::Range {
80+
LDAP2EscapeFilterCall() {
81+
this =
82+
API::moduleImport("ldap").getMember("filter").getMember("escape_filter_chars").getACall()
83+
}
84+
85+
override DataFlow::Node getAnInput() { result = this.getArg(0) }
86+
}
87+
}
88+
89+
/**
90+
* Provides models for the `ldap3` PyPI package
91+
*
92+
* See https://pypi.org/project/ldap3/
93+
*/
94+
private module LDAP3 {
95+
/**
96+
* A class to find `ldap3` methods executing a query.
97+
*/
98+
private class LDAP3Query extends DataFlow::CallCfgNode, LDAPQuery::Range {
99+
DataFlow::Node ldapQuery;
100+
101+
LDAP3Query() {
102+
exists(DataFlow::AttrRead searchMethod |
103+
this.getFunction() = searchMethod and
104+
API::moduleImport("ldap3").getMember("Connection").getACall() =
105+
searchMethod.getObject().getALocalSource() and
106+
searchMethod.getAttributeName() = "search" and
107+
(
108+
ldapQuery = this.getArg(0) or
109+
ldapQuery = this.getArg(1)
110+
)
111+
)
112+
}
113+
114+
override DataFlow::Node getQuery() { result = ldapQuery }
115+
}
116+
117+
/**
118+
* A class to find calls to `ldap3.utils.dn.escape_rdn`.
119+
*
120+
* See https://github.com/cannatag/ldap3/blob/4d33166f0869b929f59c6e6825a1b9505eb99967/ldap3/utils/dn.py#L390
121+
*/
122+
private class LDAP3EscapeDNCall extends DataFlow::CallCfgNode, LDAPEscape::Range {
123+
LDAP3EscapeDNCall() {
124+
this =
125+
API::moduleImport("ldap3")
126+
.getMember("utils")
127+
.getMember("dn")
128+
.getMember("escape_rdn")
129+
.getACall()
130+
}
131+
132+
override DataFlow::Node getAnInput() { result = this.getArg(0) }
133+
}
134+
135+
/**
136+
* A class to find calls to `ldap3.utils.conv.escape_filter_chars`.
137+
*
138+
* See https://github.com/cannatag/ldap3/blob/4d33166f0869b929f59c6e6825a1b9505eb99967/ldap3/utils/conv.py#L91
139+
*/
140+
private class LDAP3EscapeFilterCall extends DataFlow::CallCfgNode, LDAPEscape::Range {
141+
LDAP3EscapeFilterCall() {
142+
this =
143+
API::moduleImport("ldap3")
144+
.getMember("utils")
145+
.getMember("conv")
146+
.getMember("escape_filter_chars")
147+
.getACall()
148+
}
149+
150+
override DataFlow::Node getAnInput() { result = this.getArg(0) }
151+
}
152+
}
153+
}

0 commit comments

Comments
 (0)