Skip to content

Commit 09cf76a

Browse files
committed
Ruby: additional unsafe deserialization sinks for ox, oj
1 parent 049ba54 commit 09cf76a

File tree

10 files changed

+312
-113
lines changed

10 files changed

+312
-113
lines changed

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

Lines changed: 117 additions & 21 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
/**
@@ -197,9 +197,9 @@ module UnsafeDeserialization {
197197
*/
198198
predicate hasExplicitKnownMode(boolean isSafe) {
199199
exists(DataFlow::Node arg, int i | i >= 1 and arg = this.getArgument(i) |
200-
arg.(OjOptionsHashWithModeKey).hasKnownMode(isSafe)
200+
arg.(OptionsHashWithModeKey).hasKnownMode(getAKnownOjModeName(isSafe))
201201
or
202-
isOjModePair(arg.asExpr(), getAKnownOjModeName(isSafe))
202+
isModePair(arg.asExpr(), getAKnownOjModeName(isSafe))
203203
)
204204
}
205205
}
@@ -223,13 +223,109 @@ module UnsafeDeserialization {
223223
// anywhere to set the default options to a known safe mode.
224224
not ojLoad.hasExplicitKnownMode(_) and
225225
not exists(SetOjDefaultOptionsCall setOpts |
226-
setOpts.getValue().(OjOptionsHashWithModeKey).hasSafeMode()
226+
setOpts.getValue().(OptionsHashWithModeKey).hasKnownMode(getAKnownOjModeName(true))
227227
)
228228
)
229229
)
230230
}
231231
}
232232

233+
/**
234+
* The first argument in a call to `Oj.object_load`, always considered as a
235+
* sink for unsafe deserialization. (global and local mode options are ignored)
236+
*/
237+
class OjObjectLoadArgument extends Sink {
238+
OjObjectLoadArgument() {
239+
this = API::getTopLevelMember("Oj").getAMethodCall("object_load").getArgument(0)
240+
}
241+
}
242+
243+
/**
244+
* Unsafe deserialization utilizing the Ox gem
245+
* See: https://github.com/ohler55/ox
246+
*/
247+
private string getAKnownOxModeName(boolean isSafe) {
248+
result = ["generic", "limited", "hash", "hash_no_attrs"] and isSafe = true
249+
or
250+
result = "object" and isSafe = false
251+
}
252+
253+
/**
254+
* A call node that sets `Ox.default_options`.
255+
*
256+
* ```rb
257+
* Ox.default_options = { mode: :limited, effort: :tolerant }
258+
* ```
259+
*/
260+
private class SetOxDefaultOptionsCall extends DataFlow::CallNode {
261+
SetOxDefaultOptionsCall() {
262+
this = API::getTopLevelMember("Ox").getAMethodCall("default_options=")
263+
}
264+
265+
/**
266+
* Gets the value being assigned to `Ox.default_options`.
267+
*/
268+
DataFlow::Node getValue() {
269+
result.asExpr() =
270+
this.getArgument(0).asExpr().(CfgNodes::ExprNodes::AssignExprCfgNode).getRhs()
271+
}
272+
}
273+
274+
/**
275+
* A call to `Ox.load`.
276+
*/
277+
private class OxLoadCall extends DataFlow::CallNode {
278+
OxLoadCall() { this = API::getTopLevelMember("Ox").getAMethodCall("load") }
279+
280+
/**
281+
* Holds if this call to `Ox.load` includes an explicit options hash
282+
* argument that sets the mode to one that is known to be `isSafe`.
283+
*/
284+
predicate hasExplicitKnownMode(boolean isSafe) {
285+
exists(DataFlow::Node arg, int i | i >= 1 and arg = this.getArgument(i) |
286+
arg.(OptionsHashWithModeKey).hasKnownMode(getAKnownOxModeName(isSafe))
287+
or
288+
isModePair(arg.asExpr(), getAKnownOxModeName(isSafe))
289+
)
290+
}
291+
}
292+
293+
/**
294+
* An argument in a call to `Ox.load` where the mode is `:object` (not the default),
295+
* considered a sink for unsafe deserialization.
296+
*/
297+
class UnsafeOxLoadArgument extends Sink {
298+
UnsafeOxLoadArgument() {
299+
exists(OxLoadCall oxLoad |
300+
this = oxLoad.getArgument(0) and
301+
// Exclude calls that explicitly pass a safe mode option.
302+
not oxLoad.hasExplicitKnownMode(true) and
303+
(
304+
// Sinks to include:
305+
// - Calls with an explicit, unsafe mode option.
306+
oxLoad.hasExplicitKnownMode(false)
307+
or
308+
// - Calls with no explicit mode option and there exists a call
309+
// anywhere to set the default options to an unsafe mode (object).
310+
not oxLoad.hasExplicitKnownMode(_) and
311+
exists(SetOxDefaultOptionsCall setOpts |
312+
setOpts.getValue().(OptionsHashWithModeKey).hasKnownMode(getAKnownOxModeName(false))
313+
)
314+
)
315+
)
316+
}
317+
}
318+
319+
/**
320+
* The first argument in a call to `Ox.parse_obj`, always considered as a
321+
* sink for unsafe deserialization.
322+
*/
323+
class OxParseObjArgument extends Sink {
324+
OxParseObjArgument() {
325+
this = API::getTopLevelMember("Ox").getAMethodCall("parse_obj").getArgument(0)
326+
}
327+
}
328+
233329
/**
234330
* An argument in a call to `Plist.parse_xml` where `marshal` is `true` (which is
235331
* 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)