161 lines
4.8 KiB
Ruby
161 lines
4.8 KiB
Ruby
# ©AngelaMos | 2026
|
|
# app.rb
|
|
# frozen_string_literal: true
|
|
|
|
require "sinatra/base"
|
|
require "base64"
|
|
require "erb"
|
|
require "yaml"
|
|
require "marshalsea"
|
|
|
|
module Marshalsea
|
|
module Target
|
|
COOKIE_NAME = "session_state"
|
|
CANARY_PATH = "/tmp/marshalsea-canary"
|
|
|
|
STATUS_OK = 200
|
|
STATUS_BAD_REQUEST = 400
|
|
|
|
CONTENT_TYPE = "text/plain"
|
|
|
|
PERMITTED_CLASS_NAMES = [].freeze
|
|
BENIGN_TEMPLATE = "hello"
|
|
|
|
SESSION_KEYS = %i[user template].freeze
|
|
|
|
DETECTOR = Marshalsea::Marshal::BoundaryDetector.new(
|
|
policy: Marshalsea::Marshal::BoundaryDetector::POLICY_STRICT_ALLOWLIST,
|
|
allowed_class_names: PERMITTED_CLASS_NAMES,
|
|
limits: Marshalsea::Marshal::Limits.new
|
|
)
|
|
|
|
INSPECTOR = Marshalsea::Psych::Inspector.new(
|
|
permitted_class_names: PERMITTED_CLASS_NAMES,
|
|
limits: Marshalsea::Psych::Limits.new
|
|
)
|
|
|
|
YAML_PERMITTED_CLASSES = [Symbol].freeze
|
|
REFUSED_BY_PSYCH = "Psych refused the tag before reviving it: %s"
|
|
|
|
REJECTED = "rejected: %s"
|
|
RENDERED = "rendered template for %s"
|
|
NO_SESSION = "no session cookie"
|
|
NOT_A_SESSION = "payload is not a session hash"
|
|
REFUSED_BY_LOADER = "stream passed inspection and Marshal.load still refused it"
|
|
LOADER_REFUSED = Object.new.freeze
|
|
|
|
class App < Sinatra::Base
|
|
set :host_authorization, permitted_hosts: []
|
|
set :show_exceptions, false
|
|
set :dump_errors, false
|
|
|
|
get "/" do
|
|
content_type CONTENT_TYPE
|
|
[
|
|
"marshalsea target",
|
|
"erb #{Gem::Specification.find_all_by_name('erb').map(&:version).max}",
|
|
"ruby #{RUBY_VERSION}",
|
|
"",
|
|
"POST /session issue a benign session cookie",
|
|
"GET /render deserialize and compile the session template (VULNERABLE)",
|
|
"GET /render/safe inspect the stream before deserializing (DEFENDED)",
|
|
"GET /yaml/unsafe YAML.unsafe_load the same session (VULNERABLE)",
|
|
"GET /yaml/safe inspect, then YAML.safe_load, which vetoes the tag (DEFENDED)",
|
|
"GET /canary report whether the canary file exists"
|
|
].join("\n")
|
|
end
|
|
|
|
post "/session" do
|
|
state = { user: "guest", template: BENIGN_TEMPLATE }
|
|
response.set_cookie(COOKIE_NAME, value: encode(state), path: "/")
|
|
content_type CONTENT_TYPE
|
|
"session issued"
|
|
end
|
|
|
|
get "/render" do
|
|
content_type CONTENT_TYPE
|
|
blob = decode(request.cookies[COOKIE_NAME])
|
|
halt STATUS_BAD_REQUEST, NO_SESSION unless blob
|
|
|
|
state = ::Marshal.load(blob)
|
|
compile(state)
|
|
end
|
|
|
|
get "/render/safe" do
|
|
content_type CONTENT_TYPE
|
|
blob = decode(request.cookies[COOKIE_NAME])
|
|
halt STATUS_BAD_REQUEST, NO_SESSION unless blob
|
|
|
|
decision = DETECTOR.inspect_stream(blob)
|
|
halt STATUS_BAD_REQUEST, format(REJECTED, decision.reason) unless decision.proceed?
|
|
|
|
state = revive(decision.snapshot)
|
|
halt STATUS_BAD_REQUEST, format(REJECTED, REFUSED_BY_LOADER) if state.equal?(LOADER_REFUSED)
|
|
halt STATUS_BAD_REQUEST, format(REJECTED, NOT_A_SESSION) unless session?(state)
|
|
|
|
compile(state)
|
|
end
|
|
|
|
get "/yaml/unsafe" do
|
|
content_type CONTENT_TYPE
|
|
document = decode(request.cookies[COOKIE_NAME])
|
|
halt STATUS_BAD_REQUEST, NO_SESSION unless document
|
|
|
|
compile(::YAML.unsafe_load(document))
|
|
end
|
|
|
|
get "/yaml/safe" do
|
|
content_type CONTENT_TYPE
|
|
document = decode(request.cookies[COOKIE_NAME])
|
|
halt STATUS_BAD_REQUEST, NO_SESSION unless document
|
|
|
|
decision = INSPECTOR.inspect_document(document)
|
|
halt STATUS_BAD_REQUEST, format(REJECTED, decision.reason) unless decision.proceed?
|
|
|
|
compile(safe_load(document))
|
|
end
|
|
|
|
get "/canary" do
|
|
content_type CONTENT_TYPE
|
|
File.exist?(CANARY_PATH) ? File.read(CANARY_PATH) : "absent"
|
|
end
|
|
|
|
private
|
|
|
|
def encode(state)
|
|
Base64.strict_encode64(::Marshal.dump(state))
|
|
end
|
|
|
|
def decode(raw)
|
|
return nil unless raw
|
|
|
|
Base64.strict_decode64(raw)
|
|
rescue ArgumentError
|
|
nil
|
|
end
|
|
|
|
def safe_load(document)
|
|
::YAML.safe_load(document, permitted_classes: YAML_PERMITTED_CLASSES, aliases: false)
|
|
rescue ::Psych::DisallowedClass, ::Psych::AliasesNotEnabled, ::Psych::SyntaxError => e
|
|
halt STATUS_BAD_REQUEST, format(REJECTED, format(REFUSED_BY_PSYCH, e.class))
|
|
end
|
|
|
|
def revive(blob)
|
|
::Marshal.load(blob)
|
|
rescue ArgumentError, TypeError
|
|
LOADER_REFUSED
|
|
end
|
|
|
|
def session?(state)
|
|
state.is_a?(Hash) && SESSION_KEYS.all? { |key| state.key?(key) }
|
|
end
|
|
|
|
def compile(state)
|
|
template = state[:template]
|
|
template.def_method(Module.new, "render_it") if template.respond_to?(:def_method)
|
|
format(RENDERED, state[:user])
|
|
end
|
|
end
|
|
end
|
|
end
|