Ruby 1.8.6 has a bug where Dir.glob
will glob on a tainted string in $SAFE
level 3 or 4 without raising a SecurityError
as would be expected. You can see this from the following code:
lambda { $SAFE = 4; Dir.glob('/**') }.call # raises SecurityError lambda { $SAFE = 4; Dir.glob(['/**']) }.call # => [ ... files ... ]
So I set out to fix this with pure ruby… and it ended up requiring some really crazy stuff. I’ll first show what I ended up with, then go through and explain why:
class Dir class << self safe_level_password = File.open('/dev/urandom','r'){|f| f.read(10000) } m = Dir.method(:glob) define_method :glob do |password, safe, *args| raise SecurityError if safe_level_password != password $SAFE = safe args.flatten! # pass along glob opts opts = args.last.is_a?(Fixnum) ? args.pop : [] args.flatten.map do |arg| m.call(arg, *opts) end.flatten end eval %{ alias safe_glob glob def glob(*a) safe_glob(#{safe_level_password.inspect}, $SAFE, *a) end } end end # freeze Dir so that no one can redefine safe_glob* to catch password Dir.freeze
So first things first. The simple way to fix this bug is to alias glob
, iterate over the array passed to the new glob
and then call the original glob
with one argument at a time, since we know it correctly checks taint with only one argument.
But wait, if we alias the original method then someone could still access the original and pass it an array. So we have to use a “secure” version of alias method chaining. Essentially, we capture the method object of the original method in the local scope, then we use define_method
to overwrite the original method name with our new implementation and call the original method object which we have ‘closed’ over. This allows us access to the original method while preventing anyone else from doing so.
But there’s another problem. $SAFE
levels are captured in blocks just as local variables. This means our define_method
version of glob
will always run at the $SAFE
level it was defined in, namely $SAFE
level 0. Meaning if you call Dir.glob
from a $SAFE
level 4 it will still get executed at level 0. This is of course the exact opposite of what we want. We are in a worse position now than before. Now we could call Dir.glob
with a single tainted parameter and it would work.
How do we fix this? We need to use def
to define the method so that the current $SAFE
level 0 isn’t captured. Instead $SAFE
will reflect the $SAFE
level of the caller. But if we use def
we can’t use the secure alias method technique.
One option is to have the define_method
version set the $SAFE
level explicitly before calling glob
. But then we run into the issue of how to know what to set it to? There is no way of determining the $SAFE
level of the caller without explicitly passing it in.
Ok, so what if we def
a method which then calls the define_method
method and passes its $SAFE
level as an argument? Well then the problem is how do you give the def
method access to the define_method
method without giving other evil code access to the define_method
method as well. Because then that evil code could just lie and pass a level of 0.
This is the crazy part. The way to prevent the evil method from executing the define_method
method is to use a password!
Both the def
and define_method
methods can share a secret which is only available in the local scope where they are defined. Since the password is only a string we can use eval
to create the def
method and insert the password into it. As long as the define_method
method verifies the secret is correct before continuing we can be sure the only method able to call it is the def
method.
I never thought I’d be sharing a secret between methods. I know this is a big house of cards. Can anyone figure out how to make it tumble? Or a better way to fix the glob
bug (without C).
And yes, you must also do the same for Dir.[]
but I left it out for sake of brevity.
BTW, here’s the patch if you actually care enough to recompile:
--- dir.c.orig 2009-02-21 21:49:09.000000000 +++ dir.c 2009-02-21 21:49:38.000000000 @@ -1659,7 +1659,7 @@ for (i = 0; i < argc; ++i) { int status; VALUE str = argv[i]; - StringValue(str); + SafeStringValue(str); status = push_glob(ary, RSTRING(str)->ptr, flags); if (status) GLOB_JUMP_TAG(status); }