Skip to content

Commit 4591560

Browse files
authored
Merge pull request #14544 from p-/p--oj-ox-unsafe-deser
Ruby: additional unsafe deserialization sinks for ox and one for oj
2 parents f557110 + fb075a9 commit 4591560

File tree

10 files changed

+310
-117
lines changed

10 files changed

+310
-117
lines changed

ruby/ql/lib/codeql/ruby/security/UnsafeDeserializationCustomizations.qll

Lines changed: 115 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -126,42 +126,42 @@ module UnsafeDeserialization {
126126
}
127127
}
128128

129-
private string getAKnownOjModeName(boolean isSafe) {
130-
result = ["compat", "custom", "json", "null", "rails", "strict", "wab"] and isSafe = true
131-
or
132-
result = "object" and isSafe = false
133-
}
134-
135-
private predicate isOjModePair(CfgNodes::ExprNodes::PairCfgNode p, string modeValue) {
129+
/**
130+
* Oj/Ox common code to establish whether a deserialization mode is defined.
131+
*/
132+
private predicate isModePair(CfgNodes::ExprNodes::PairCfgNode p, string modeValue) {
136133
p.getKey().getConstantValue().isStringlikeValue("mode") and
137134
DataFlow::exprNode(p.getValue()).getALocalSource().getConstantValue().isSymbol(modeValue)
138135
}
139136

140137
/**
141138
* A node representing a hash that contains the key `:mode`.
142139
*/
143-
private class OjOptionsHashWithModeKey extends DataFlow::Node {
140+
private class OptionsHashWithModeKey extends DataFlow::Node {
144141
private string modeValue;
145142

146-
OjOptionsHashWithModeKey() {
143+
OptionsHashWithModeKey() {
147144
exists(DataFlow::LocalSourceNode options |
148145
options.flowsTo(this) and
149-
isOjModePair(options.asExpr().(CfgNodes::ExprNodes::HashLiteralCfgNode).getAKeyValuePair(),
146+
isModePair(options.asExpr().(CfgNodes::ExprNodes::HashLiteralCfgNode).getAKeyValuePair(),
150147
modeValue)
151148
)
152149
}
153150

154151
/**
155-
* Holds if this hash node contains a `:mode` key whose value is one known
156-
* to be `isSafe` with untrusted data.
152+
* Holds if this hash node contains the `:mode`
157153
*/
158-
predicate hasKnownMode(boolean isSafe) { modeValue = getAKnownOjModeName(isSafe) }
154+
predicate hasKnownMode(string mode) { modeValue = mode }
155+
}
159156

160-
/**
161-
* Holds if this hash node contains a `:mode` key whose value is one of the
162-
* `Oj` modes known to be safe to use with untrusted data.
163-
*/
164-
predicate hasSafeMode() { this.hasKnownMode(true) }
157+
/**
158+
* Unsafe deserialization utilizing the Oj gem
159+
* See: https://github.com/ohler55/oj
160+
*/
161+
private string getAKnownOjModeName(boolean isSafe) {
162+
result = ["compat", "custom", "json", "null", "rails", "strict", "wab"] and isSafe = true
163+
or
164+
result = "object" and isSafe = false
165165
}
166166

167167
/**
@@ -179,10 +179,7 @@ module UnsafeDeserialization {
179179
/**
180180
* Gets the value being assigned to `Oj.default_options`.
181181
*/
182-
DataFlow::Node getValue() {
183-
result.asExpr() =
184-
this.getArgument(0).asExpr().(CfgNodes::ExprNodes::AssignExprCfgNode).getRhs()
185-
}
182+
DataFlow::Node getValue() { result = this.getArgument(0) }
186183
}
187184

188185
/**
@@ -197,9 +194,9 @@ module UnsafeDeserialization {
197194
*/
198195
predicate hasExplicitKnownMode(boolean isSafe) {
199196
exists(DataFlow::Node arg, int i | i >= 1 and arg = this.getArgument(i) |
200-
arg.(OjOptionsHashWithModeKey).hasKnownMode(isSafe)
197+
arg.(OptionsHashWithModeKey).hasKnownMode(getAKnownOjModeName(isSafe))
201198
or
202-
isOjModePair(arg.asExpr(), getAKnownOjModeName(isSafe))
199+
isModePair(arg.asExpr(), getAKnownOjModeName(isSafe))
203200
)
204201
}
205202
}
@@ -223,13 +220,106 @@ module UnsafeDeserialization {
223220
// anywhere to set the default options to a known safe mode.
224221
not ojLoad.hasExplicitKnownMode(_) and
225222
not exists(SetOjDefaultOptionsCall setOpts |
226-
setOpts.getValue().(OjOptionsHashWithModeKey).hasSafeMode()
223+
setOpts.getValue().(OptionsHashWithModeKey).hasKnownMode(getAKnownOjModeName(true))
224+
)
225+
)
226+
)
227+
}
228+
}
229+
230+
/**
231+
* The first argument in a call to `Oj.object_load`, always considered as a
232+
* sink for unsafe deserialization. (global and local mode options are ignored)
233+
*/
234+
private class OjObjectLoadArgument extends Sink {
235+
OjObjectLoadArgument() {
236+
this = API::getTopLevelMember("Oj").getAMethodCall("object_load").getArgument(0)
237+
}
238+
}
239+
240+
/**
241+
* Unsafe deserialization utilizing the Ox gem
242+
* See: https://github.com/ohler55/ox
243+
*/
244+
private string getAKnownOxModeName(boolean isSafe) {
245+
result = ["generic", "limited", "hash", "hash_no_attrs"] and isSafe = true
246+
or
247+
result = "object" and isSafe = false
248+
}
249+
250+
/**
251+
* A call node that sets `Ox.default_options`.
252+
*
253+
* ```rb
254+
* Ox.default_options = { mode: :limited, effort: :tolerant }
255+
* ```
256+
*/
257+
private class SetOxDefaultOptionsCall extends DataFlow::CallNode {
258+
SetOxDefaultOptionsCall() {
259+
this = API::getTopLevelMember("Ox").getAMethodCall("default_options=")
260+
}
261+
262+
/**
263+
* Gets the value being assigned to `Ox.default_options`.
264+
*/
265+
DataFlow::Node getValue() { result = this.getArgument(0) }
266+
}
267+
268+
/**
269+
* A call to `Ox.load`.
270+
*/
271+
private class OxLoadCall extends DataFlow::CallNode {
272+
OxLoadCall() { this = API::getTopLevelMember("Ox").getAMethodCall("load") }
273+
274+
/**
275+
* Holds if this call to `Ox.load` includes an explicit options hash
276+
* argument that sets the mode to one that is known to be `isSafe`.
277+
*/
278+
predicate hasExplicitKnownMode(boolean isSafe) {
279+
exists(DataFlow::Node arg, int i | i >= 1 and arg = this.getArgument(i) |
280+
arg.(OptionsHashWithModeKey).hasKnownMode(getAKnownOxModeName(isSafe))
281+
or
282+
isModePair(arg.asExpr(), getAKnownOxModeName(isSafe))
283+
)
284+
}
285+
}
286+
287+
/**
288+
* An argument in a call to `Ox.load` where the mode is `:object` (not the default),
289+
* considered a sink for unsafe deserialization.
290+
*/
291+
class UnsafeOxLoadArgument extends Sink {
292+
UnsafeOxLoadArgument() {
293+
exists(OxLoadCall oxLoad |
294+
this = oxLoad.getArgument(0) and
295+
// Exclude calls that explicitly pass a safe mode option.
296+
not oxLoad.hasExplicitKnownMode(true) and
297+
(
298+
// Sinks to include:
299+
// - Calls with an explicit, unsafe mode option.
300+
oxLoad.hasExplicitKnownMode(false)
301+
or
302+
// - Calls with no explicit mode option and there exists a call
303+
// anywhere to set the default options to an unsafe mode (object).
304+
not oxLoad.hasExplicitKnownMode(_) and
305+
exists(SetOxDefaultOptionsCall setOpts |
306+
setOpts.getValue().(OptionsHashWithModeKey).hasKnownMode(getAKnownOxModeName(false))
227307
)
228308
)
229309
)
230310
}
231311
}
232312

313+
/**
314+
* The first argument in a call to `Ox.parse_obj`, always considered as a
315+
* sink for unsafe deserialization.
316+
*/
317+
class OxParseObjArgument extends Sink {
318+
OxParseObjArgument() {
319+
this = API::getTopLevelMember("Ox").getAMethodCall("parse_obj").getArgument(0)
320+
}
321+
}
322+
233323
/**
234324
* An argument in a call to `Plist.parse_xml` where `marshal` is `true` (which is
235325
* the default), considered a sink for unsafe deserialization.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
category: minorAnalysis
3+
---
4+
* Added new unsafe deserialization sinks for the ox gem.
5+
* Added an additional unsafe deserialization sink for the oj gem.

ruby/ql/src/queries/security/cwe-502/UnsafeDeserialization.qhelp

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ method. In <code>psych</code> version 4.0.0 and above, the <code>load</code> met
2727
safely be used.
2828
</p>
2929

30+
<p>
31+
If deserializing an untrusted XML document using the <code>ox</code> gem,
32+
do not use <code>parse_obj</code> and <code>load</code> using the non-default :object mode.
33+
Instead use the <code>load</code> method in the default mode or better explicitly set a safe
34+
mode such as :hash.
35+
</p>
36+
3037
<p>
3138
To safely deserialize <a href="https://en.wikipedia.org/wiki/Property_list">Property List</a>
3239
files using the <code>plist</code> gem, ensure that you pass <code>marshal: false</code>
@@ -37,8 +44,8 @@ when calling <code>Plist.parse_xml</code>.
3744
<example>
3845
<p>
3946
The following example calls the <code>Marshal.load</code>,
40-
<code>JSON.load</code>, <code>YAML.load</code>, and <code>Oj.load</code> methods
41-
on data from an HTTP request. Since these methods are capable of deserializing
47+
<code>JSON.load</code>, <code>YAML.load</code>, <code>Oj.load</code> and <code>Ox.parse_obj</code>
48+
methods on data from an HTTP request. Since these methods are capable of deserializing
4249
to arbitrary objects, this is inherently unsafe.
4350
</p>
4451
<sample src="examples/UnsafeDeserializationBad.rb"/>

ruby/ql/src/queries/security/cwe-502/examples/UnsafeDeserializationBad.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,9 @@ def oj_example
2323
object = Oj.load params[:json]
2424
# ...
2525
end
26+
27+
def ox_example
28+
object = Ox.parse_obj params[:xml]
29+
# ...
30+
end
2631
end
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
require "ox"
2+
3+
class UsersController < ActionController::Base
4+
# BAD - Ox.load is unsafe when the mode :object is set globally
5+
def route0
6+
xml_data = params[:key]
7+
object = Ox.load xml_data
8+
end
9+
10+
# GOOD - the unsafe mode set globally is overridden with an insecure mode passed as
11+
# a call argument
12+
def route1
13+
xml_data = params[:key]
14+
object = Ox.load xml_data, mode: :generic
15+
end
16+
end
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
require "ox"
2+
3+
# Set the default mode for the Ox library to use the :object mode, which makes
4+
# Ox.load unsafe for untrusted data.
5+
Ox.default_options = { :mode => :object }
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
edges
2+
| OxGlobalOptions.rb:6:5:6:12 | xml_data | OxGlobalOptions.rb:7:22:7:29 | xml_data |
3+
| OxGlobalOptions.rb:6:16:6:21 | call to params | OxGlobalOptions.rb:6:16:6:27 | ...[...] |
4+
| OxGlobalOptions.rb:6:16:6:27 | ...[...] | OxGlobalOptions.rb:6:5:6:12 | xml_data |
5+
nodes
6+
| OxGlobalOptions.rb:6:5:6:12 | xml_data | semmle.label | xml_data |
7+
| OxGlobalOptions.rb:6:16:6:21 | call to params | semmle.label | call to params |
8+
| OxGlobalOptions.rb:6:16:6:27 | ...[...] | semmle.label | ...[...] |
9+
| OxGlobalOptions.rb:7:22:7:29 | xml_data | semmle.label | xml_data |
10+
subpaths
11+
#select
12+
| OxGlobalOptions.rb:7:22:7:29 | xml_data | OxGlobalOptions.rb:6:16:6:21 | call to params | OxGlobalOptions.rb:7:22:7:29 | xml_data | Unsafe deserialization depends on a $@. | OxGlobalOptions.rb:6:16:6:21 | call to params | user-provided value |
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
queries/security/cwe-502/UnsafeDeserialization.ql

0 commit comments

Comments
 (0)