Skip to content

Commit f71c99a

Browse files
authored
Merge pull request github#5444 from jorgectf/jorgectf/python/ldapimproperauth
Python: Add LDAP Improper Authentication query
2 parents 802d9bd + 42a997c commit f71c99a

File tree

14 files changed

+496
-50
lines changed

14 files changed

+496
-50
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<!DOCTYPE qhelp PUBLIC
2+
"-//Semmle//qhelp//EN"
3+
"qhelp.dtd">
4+
<qhelp>
5+
<overview>
6+
<p>If an LDAP query doesn't carry any kind of authentication, anonymous binds causes an empty or None-set password
7+
to result in a successful authentication.</p>
8+
</overview>
9+
10+
<recommendation>
11+
<p>Use a non-empty password while establishing an LDAP connection.</p>
12+
</recommendation>
13+
14+
<example>
15+
<p>In the following examples, the code builds a LDAP query whose execution carries no authentication or binds anonymously.</p>
16+
17+
<sample src="examples/auth_bad_2.py" />
18+
<sample src="examples/auth_bad_3.py" />
19+
20+
<p>In the third and fourth examples, the authentication is established using a password.</p>
21+
22+
<sample src="examples/auth_good_2.py" />
23+
<sample src="examples/auth_good_3.py" />
24+
</example>
25+
26+
<references>
27+
<li>SonarSource: <a href="https://rules.sonarsource.com/python/type/Vulnerability/RSPEC-4433">RSPEC-4433</a>.</li>
28+
<li>Python2: <a href="https://www.python-ldap.org/en/python-ldap-3.3.0/reference/ldap.html">LDAP Documentation</a>.</li>
29+
<li>Python3: <a href="https://ldap3.readthedocs.io/en/latest/">LDAP Documentation</a>.</li>
30+
</references>
31+
</qhelp>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* @name Improper LDAP Authentication
3+
* @description A user-controlled query carries no authentication
4+
* @kind problem
5+
* @problem.severity warning
6+
* @id py/improper-ldap-auth
7+
* @tags experimental
8+
* security
9+
* external/cwe/cwe-287
10+
*/
11+
12+
// Determine precision above
13+
import python
14+
import experimental.semmle.python.Concepts
15+
import semmle.python.dataflow.new.DataFlow
16+
17+
predicate authenticatesImproperly(LDAPBind ldapBind) {
18+
(
19+
DataFlow::localFlow(DataFlow::exprNode(any(None noneName)), ldapBind.getPassword()) or
20+
not exists(ldapBind.getPassword())
21+
)
22+
or
23+
exists(StrConst emptyString |
24+
emptyString.getText() = "" and
25+
DataFlow::localFlow(DataFlow::exprNode(emptyString), ldapBind.getPassword())
26+
)
27+
}
28+
29+
from LDAPBind ldapBind
30+
where authenticatesImproperly(ldapBind)
31+
select ldapBind, "The following LDAP bind operation is executed without authentication"
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from flask import request, Flask
2+
import ldap
3+
import ldap.filter
4+
import ldap.dn
5+
6+
7+
@app.route("/bind_example")
8+
def bind_example():
9+
dn = "dc={}".format(ldap.dn.escape_dn_chars(request.args['dc']))
10+
search_filter = "(user={})".format(ldap.filter.escape_filter_chars(request.args['search']))
11+
12+
ldap_connection = ldap.initialize("ldap://127.0.0.1:1337")
13+
ldap_connection.bind('cn=root', "")
14+
user = ldap_connection.search_s(dn, ldap.SCOPE_SUBTREE, search_filter)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from ldap3 import Server, Connection, ALL
2+
from flask import request, Flask
3+
from ldap3.utils.dn import escape_rdn
4+
from ldap3.utils.conv import escape_filter_chars
5+
6+
@app.route("/passwordNone")
7+
def passwordNone():
8+
dn = "dc={}".format(escape_rdn(request.args['dc']))
9+
search_filter = "(user={})".format(escape_filter_chars(request.args['search']))
10+
11+
srv = Server('servername', get_info=ALL)
12+
conn = Connection(srv, user='user_dn', password=None)
13+
status, result, response, _ = conn.search(dn, search_filter)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from flask import request, Flask
2+
import ldap
3+
import ldap.filter
4+
import ldap.dn
5+
6+
7+
@app.route("/bind_example")
8+
def bind_example():
9+
dn = "dc={}".format(ldap.dn.escape_dn_chars(request.args['dc']))
10+
search_filter = "(user={})".format(ldap.filter.escape_filter_chars(request.args['search']))
11+
12+
ldap_connection = ldap.initialize("ldap://127.0.0.1:1337")
13+
ldap_connection.bind('cn=root', "SecurePa$$!")
14+
user = ldap_connection.search_s(dn, ldap.SCOPE_SUBTREE, search_filter)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from ldap3 import Server, Connection, ALL
2+
from flask import request, Flask
3+
from ldap3.utils.dn import escape_rdn
4+
from ldap3.utils.conv import escape_filter_chars
5+
6+
@app.route("/passwordFromEnv")
7+
def passwordFromEnv():
8+
dn = "dc={}".format(escape_rdn(request.args['dc']))
9+
search_filter = "(user={})".format(escape_filter_chars(request.args['search']))
10+
11+
srv = Server('servername', get_info=ALL)
12+
conn = Connection(srv, user='user_dn',
13+
password="SecurePa$$!")
14+
status, result, response, _ = conn.search(dn, search_filter)

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,36 @@ class LDAPEscape extends DataFlow::Node {
147147
DataFlow::Node getAnInput() { result = range.getAnInput() }
148148
}
149149

150+
/** Provides classes for modeling LDAP bind-related APIs. */
151+
module LDAPBind {
152+
/**
153+
* A data-flow node that collects methods binding a LDAP connection.
154+
*
155+
* Extend this class to model new APIs. If you want to refine existing API models,
156+
* extend `LDAPBind` instead.
157+
*/
158+
abstract class Range extends DataFlow::Node {
159+
/**
160+
* Gets the argument containing the binding expression.
161+
*/
162+
abstract DataFlow::Node getPassword();
163+
}
164+
}
165+
166+
/**
167+
* A data-flow node that collects methods binding a LDAP connection.
168+
*
169+
* Extend this class to refine existing API models. If you want to model new APIs,
170+
* extend `LDAPBind::Range` instead.
171+
*/
172+
class LDAPBind extends DataFlow::Node {
173+
LDAPBind::Range range;
174+
175+
LDAPBind() { this = range }
176+
177+
DataFlow::Node getPassword() { result = range.getPassword() }
178+
}
179+
150180
/** Provides classes for modeling SQL sanitization libraries. */
151181
module SQLEscape {
152182
/**

python/ql/src/experimental/semmle/python/frameworks/LDAP.qll

Lines changed: 92 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@ private module LDAP {
1919
* See https://www.python-ldap.org/en/python-ldap-3.3.0/index.html
2020
*/
2121
private module LDAP2 {
22+
/** Gets a reference to the `ldap` module. */
23+
API::Node ldap() { result = API::moduleImport("ldap") }
24+
25+
/** Returns a `ldap` module instance */
26+
API::Node ldapInitialize() { result = ldap().getMember("initialize") }
27+
28+
/** Gets a reference to a `ldap` operation. */
29+
private DataFlow::TypeTrackingNode ldapOperation(DataFlow::TypeTracker t) {
30+
t.start() and
31+
result.(DataFlow::AttrRead).getObject().getALocalSource() = ldapInitialize().getACall()
32+
or
33+
exists(DataFlow::TypeTracker t2 | result = ldapOperation(t2).track(t2, t))
34+
}
35+
2236
/**
2337
* List of `ldap` methods used to execute a query.
2438
*
@@ -30,32 +44,61 @@ private module LDAP {
3044
}
3145
}
3246

47+
/** Gets a reference to a `ldap` operation. */
48+
private DataFlow::Node ldapOperation() {
49+
ldapOperation(DataFlow::TypeTracker::end()).flowsTo(result)
50+
}
51+
52+
/** Gets a reference to a `ldap` query. */
53+
private DataFlow::Node ldapQuery() {
54+
result = ldapOperation() and
55+
result.(DataFlow::AttrRead).getAttributeName() instanceof LDAP2QueryMethods
56+
}
57+
3358
/**
3459
* A class to find `ldap` methods executing a query.
3560
*
3661
* See `LDAP2QueryMethods`
3762
*/
3863
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-
)
64+
LDAP2Query() { this.getFunction() = ldapQuery() }
65+
66+
override DataFlow::Node getQuery() {
67+
result in [this.getArg(0), this.getArg(2), this.getArgByName("filterstr")]
68+
}
69+
}
70+
71+
/**
72+
* List of `ldap` methods used for binding.
73+
*
74+
* See https://www.python-ldap.org/en/python-ldap-3.3.0/reference/ldap.html#functions
75+
*/
76+
private class LDAP2BindMethods extends string {
77+
LDAP2BindMethods() {
78+
this in [
79+
"bind", "bind_s", "simple_bind", "simple_bind_s", "sasl_interactive_bind_s",
80+
"sasl_non_interactive_bind_s", "sasl_external_bind_s", "sasl_gssapi_bind_s"
81+
]
5682
}
83+
}
5784

58-
override DataFlow::Node getQuery() { result = ldapQuery }
85+
/** Gets a reference to a `ldap` bind. */
86+
private DataFlow::Node ldapBind() {
87+
result = ldapOperation() and
88+
result.(DataFlow::AttrRead).getAttributeName() instanceof LDAP2BindMethods
89+
}
90+
91+
/**
92+
* A class to find `ldap` methods binding a connection.
93+
*
94+
* See `LDAP2BindMethods`
95+
*/
96+
private class LDAP2Bind extends DataFlow::CallCfgNode, LDAPBind::Range {
97+
LDAP2Bind() { this.getFunction() = ldapBind() }
98+
99+
override DataFlow::Node getPassword() {
100+
result in [this.getArg(1), this.getArgByName("cred")]
101+
}
59102
}
60103

61104
/**
@@ -64,9 +107,7 @@ private module LDAP {
64107
* See https://github.com/python-ldap/python-ldap/blob/7ce471e238cdd9a4dd8d17baccd1c9e05e6f894a/Lib/ldap/dn.py#L17
65108
*/
66109
private class LDAP2EscapeDNCall extends DataFlow::CallCfgNode, LDAPEscape::Range {
67-
LDAP2EscapeDNCall() {
68-
this = API::moduleImport("ldap").getMember("dn").getMember("escape_dn_chars").getACall()
69-
}
110+
LDAP2EscapeDNCall() { this = ldap().getMember("dn").getMember("escape_dn_chars").getACall() }
70111

71112
override DataFlow::Node getAnInput() { result = this.getArg(0) }
72113
}
@@ -78,8 +119,7 @@ private module LDAP {
78119
*/
79120
private class LDAP2EscapeFilterCall extends DataFlow::CallCfgNode, LDAPEscape::Range {
80121
LDAP2EscapeFilterCall() {
81-
this =
82-
API::moduleImport("ldap").getMember("filter").getMember("escape_filter_chars").getACall()
122+
this = ldap().getMember("filter").getMember("escape_filter_chars").getACall()
83123
}
84124

85125
override DataFlow::Node getAnInput() { result = this.getArg(0) }
@@ -92,26 +132,40 @@ private module LDAP {
92132
* See https://pypi.org/project/ldap3/
93133
*/
94134
private module LDAP3 {
135+
/** Gets a reference to the `ldap3` module. */
136+
API::Node ldap3() { result = API::moduleImport("ldap3") }
137+
138+
/** Gets a reference to the `ldap3` `utils` module. */
139+
API::Node ldap3Utils() { result = ldap3().getMember("utils") }
140+
141+
/** Returns a `ldap3` module `Server` instance */
142+
API::Node ldap3Server() { result = ldap3().getMember("Server") }
143+
144+
/** Returns a `ldap3` module `Connection` instance */
145+
API::Node ldap3Connection() { result = ldap3().getMember("Connection") }
146+
95147
/**
96148
* A class to find `ldap3` methods executing a query.
97149
*/
98150
private class LDAP3Query extends DataFlow::CallCfgNode, LDAPQuery::Range {
99-
DataFlow::Node ldapQuery;
100-
101151
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-
)
152+
this.getFunction().(DataFlow::AttrRead).getObject().getALocalSource() =
153+
ldap3Connection().getACall() and
154+
this.getFunction().(DataFlow::AttrRead).getAttributeName() = "search"
112155
}
113156

114-
override DataFlow::Node getQuery() { result = ldapQuery }
157+
override DataFlow::Node getQuery() { result in [this.getArg(0), this.getArg(1)] }
158+
}
159+
160+
/**
161+
* A class to find `ldap3` methods binding a connection.
162+
*/
163+
class LDAP3Bind extends DataFlow::CallCfgNode, LDAPBind::Range {
164+
LDAP3Bind() { this = ldap3Connection().getACall() }
165+
166+
override DataFlow::Node getPassword() {
167+
result in [this.getArg(2), this.getArgByName("password")]
168+
}
115169
}
116170

117171
/**
@@ -120,14 +174,7 @@ private module LDAP {
120174
* See https://github.com/cannatag/ldap3/blob/4d33166f0869b929f59c6e6825a1b9505eb99967/ldap3/utils/dn.py#L390
121175
*/
122176
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-
}
177+
LDAP3EscapeDNCall() { this = ldap3Utils().getMember("dn").getMember("escape_rdn").getACall() }
131178

132179
override DataFlow::Node getAnInput() { result = this.getArg(0) }
133180
}
@@ -139,12 +186,7 @@ private module LDAP {
139186
*/
140187
private class LDAP3EscapeFilterCall extends DataFlow::CallCfgNode, LDAPEscape::Range {
141188
LDAP3EscapeFilterCall() {
142-
this =
143-
API::moduleImport("ldap3")
144-
.getMember("utils")
145-
.getMember("conv")
146-
.getMember("escape_filter_chars")
147-
.getACall()
189+
this = ldap3Utils().getMember("conv").getMember("escape_filter_chars").getACall()
148190
}
149191

150192
override DataFlow::Node getAnInput() { result = this.getArg(0) }
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
| auth_bad_2.py:19:5:19:42 | ControlFlowNode for Attribute() | The following LDAP bind operation is executed without authentication |
2+
| auth_bad_2.py:33:5:33:44 | ControlFlowNode for Attribute() | The following LDAP bind operation is executed without authentication |
3+
| auth_bad_2.py:47:5:47:43 | ControlFlowNode for Attribute() | The following LDAP bind operation is executed without authentication |
4+
| auth_bad_2.py:60:5:60:52 | ControlFlowNode for Attribute() | The following LDAP bind operation is executed without authentication |
5+
| auth_bad_2.py:73:5:73:39 | ControlFlowNode for Attribute() | The following LDAP bind operation is executed without authentication |
6+
| auth_bad_2.py:87:5:87:48 | ControlFlowNode for Attribute() | The following LDAP bind operation is executed without authentication |
7+
| auth_bad_3.py:19:12:19:43 | ControlFlowNode for Connection() | The following LDAP bind operation is executed without authentication |
8+
| auth_bad_3.py:33:12:33:57 | ControlFlowNode for Connection() | The following LDAP bind operation is executed without authentication |
9+
| auth_bad_3.py:46:12:46:55 | ControlFlowNode for Connection() | The following LDAP bind operation is executed without authentication |
10+
| auth_bad_3.py:60:12:60:42 | ControlFlowNode for Connection() | The following LDAP bind operation is executed without authentication |
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
experimental/Security/CWE-287/ImproperLdapAuth.ql

0 commit comments

Comments
 (0)