No tags, no releases, three commits. Only the current state of main is supported and only main
gets fixed. If you run an older commit, update before you report anything.
Use GitHub Private Vulnerability Reporting, which is enabled on this repo:
https://github.com/GodModeAI2025/mobileconfig-builder/security/advisories/new
Do not open a public issue for a security problem. This repo is public, issues are public from the first second, and this tool handles Wi-Fi passphrases, VPN shared secrets and signing keys.
Expect an acknowledgement within a few days and an assessment within two weeks. This is a one person side project without an on-call rotation. If a report sits longer than two weeks, ping the advisory thread. Useful in a report: the sanitized spec file, the exact command line, the schema cache state.
What is worth stealing. The product of this tool is a file that carries credentials in
cleartext, and everything below follows from that. A generated .mobileconfig contains whatever
secrets the spec holds, as plain XML: the shipped example carries a WPA passphrase
(assets/examples/wifi_guest.json:17), and the payload types for VPN, mail and PKCS#12 carry shared
secrets, account passwords and key material the same way. Next to the output sits the signing key,
referenced by path via --sign-key.
Attack path 1: the profile lands in git. The quick start writes into the working directory
(-o wifi.mobileconfig in README.md:26, -o guest-wifi.mobileconfig in index.html:509), which
is the repo root if you follow it literally. .gitignore now covers *.mobileconfig and key
material as a backstop, but a spec file with a real password is not covered by any pattern, and
tools/scan_secrets.py only sees what is already staged. Secret scanning and push protection are
enabled here, but they match provider token patterns, not a WPA passphrase in an XML plist, and
non-provider patterns are off.
Attack path 2: signing fails and the cleartext stays behind. Closed. Until the keychain work,
scripts/build_mobileconfig.py wrote the unsigned plist to <output>.unsigned.mobileconfig, called
sign_profile, and unlinked the temp file only afterwards. sign_profile raised when openssl
exited non-zero, so tmp.unlink() was never reached. Reproduced with a nonexistent cert path:
leak.unsigned.mobileconfig stayed on disk, mode 0644, containing
<key>Password</key><string>supersecret123</string>, next to a zero byte leak.mobileconfig that
reads like a finished profile, and the exception surfaced as a traceback. The unsigned plist now
goes to the signing tool over stdin and is never written to disk. The message is a message and not
a traceback, and the exit code is 2.
The first fix for the leftover file was half of one, and this is where it was half: the output file
was removed on failure unless it existed before the run. Which is the common case. A second build
onto the same path, after any change to the spec, hits an existing file, and both signing tools
truncate their output file when they open it. Measured with wifi_guest.json: 1378 bytes of valid
profile before the failed run, 0 bytes after, and the cleanup branch skipped the file because it
had existed. Signing now writes to a temporary file in the target directory and only moves it onto
the output path, with os.replace, once every check has passed. The output path therefore either
keeps what it had or gets a verified signature, and there is no longer a condition on when a file
may be deleted. Eval 7 asserts all of it, including that no file in the output directory contains
the example password and that an existing profile survives a failed run byte for byte.
The temporary file cost two cases of its own, and both are fixed rather than described away. A
symbolic link as the output path used to be written through, leaving the link in place; os.replace
on the link name replaced the link with a regular file and left the linked file at 0 bytes. Work
now happens on the realpath-resolved path, so the linked file gets the profile again, as it did
before. And the temporary file needs write permission on the directory, not just on the output
file: in a directory with mode 555 the run ends with exit 2 and a message, and whatever was at the
output path stays untouched. A CI step signs against all of it with a self-signed certificate:
normal case, a 254 character output name, symlink, directory as output path, and the failed second
build. That step is red against the state this repository had before these fixes.
Attack path 3: a poisoned schema cache. fetch_schema.py:76-77 returns a file from
~/.cache/mobileconfig-builder/<branch>/ whenever it exists and --refresh is not set. No TTL, no
checksum, no ETag. Whoever can write that directory decides what --validate-strict accepts.
Attack path 4: the operator trusts the validator. A profile can exit 0 under --validate-strict
and still be wrong, see gaps 3, 4 and 5. The damage is not a compromised host, it is a wrong policy
shipped to a fleet with a green check behind it.
Out of scope: the MDM server, the device, Apple's own schema, and the device trust stores.
The private signing key never enters this process. With --sign-cert it is passed by path and
handed to openssl smime as an argv value. With --sign-identity it is never named at all: the
key stays in the macOS keychain and /usr/bin/security cms signs inside it. Python reads neither.
Keep it that way in any patch.
No private keys through chat. The rule lives in references/signing.md:50-54 and belongs in a
security policy too: never paste a private key into a chat window, with Claude or any other
assistant. Give the file path. An assistant has to refuse the key and must not offer a generated
self-signed cert as a workaround, since such a profile still shows "Not Verified" on the device.
Treated as trusted, without verification:
- Every path you pass to
--sign-cert,--sign-key,--sign-ca. They are forwarded toopenssluninspected. - HTTPS responses from
api.github.comandraw.githubusercontent.com. Filenames from the API listing become cache paths directly (fetch_schema.py:58-59,69-70), filtered only by suffix. - Anything already sitting in the local schema cache.
- Every spec value that passes the schema check. Passwords are copied verbatim into the output, and
evals/run_tests.py:157-158asserts exactly that.
The boundary runs between spec file and output file. What goes into the spec is your job, and everything after the output (transport, MDM import, device install) is outside this tool.
Every item below was measured against the current main, not carried over from an earlier list.
The closed ones are named at the end, because the paragraphs further up still mention them.
- Seven payload types disable the unknown-key check.
_check_keysskips that branch when the schema declares anANYkey. Counted against the pinned release branch: 121 payload types, of which exactly seven carry such a key, all of themcom.apple.*ethernet*.managed. Unknown keys pass there without complaint, in the build and invalidate_mobileconfig.pyalike. - The unsigned output file gets default permissions.
build_mobileconfig.py:1131writes the profile withwrite_bytesand no chmod. Measured: 0644 for a profile holding a WPA passphrase, readable by everyone on a shared machine. The signed path is different, it sets the mode on its temporary file before the content goes in. - Validation errors echo the offending value.
build_mobileconfig.py:141and:156printvalue {value!r}on rangelist and regex mismatches. No password key in the current Apple schema carries such a constraint, so no leak is known today, but the code has no notion of a secret key and the message lands in stderr and in any CI log. ensure_yaml()installs a package on its own. It runspip install --quiet pyyamlon first use and retries with--break-system-packages, unpinned and without a hash check. Install PyYAML yourself beforehand if that is not acceptable.- The schema cache never expires and is never checked. Attack path 3,
--refreshis manual.get_schematakes no offline flag either: it uses what is remembered for the branch and goes to the network otherwise. Callload_all_schemas(branch, offline=True)first if a run must stay off the network. - The shipped examples contain plausible cleartext passwords.
assets/examples/wifi_guest.json:17,assets/examples/classroom_ipad.json:17. Fine as demo values, dangerous as a copy template, because a copy keeps the README output path. __file__in a spec reads any path the process can read. The marker takes the bytes as they are, with no allowlist and no size limit, and they end up in the profile in cleartext. A spec somebody else wrote deserves a look at its__file__entries before you build it.- The validator does not say who signed a profile.
validate_mobileconfig.pyunpacks a PKCS#7 container withopenssl smime -verify -noverify, which checks the signature but skips the certificate chain. A valid signature from an issuer nobody trusts passes. Nothing scans a finished profile for secrets either;tools/scan_secrets.pylooks at the repository, not at a profile you hand it.
Closed since this list was first written, and named here because the sections above still refer to
them: the cleartext .unsigned.mobileconfig left behind by a failed signing run, which no longer
touches the disk at all because the profile goes to the signing tool through stdin; a .gitignore
that covered only .DS_Store; --validate-strict ignoring the top level, which is now checked
against TopLevel.yaml; the colliding payload types that build and inspect resolved in opposite
directions, where the schema loader now merges the files and a key counts as required only when
every file demands it; _SCHEMA_CACHE without a branch key; and the missing way to check a profile
that already exists.
- No secret management. Passwords in your spec end up in the output in cleartext. That is the profile format, not a bug. Anything stricter happens before the spec and after the output.
- No payload encryption. Per device payload encryption, the way an MDM does it, is missing.
- No judgment about your certificate. EKU, expiry, key usage, chain and issuer trust are
unchecked.
opensslsigns with whatever you point it at, andsecurity cmssigns with whatever identity you name. What the tool does check is that the result is a PKCS#7 structure that unpacks back to exactly the profile it built, becausesecurity cms -Sreports success on stderr-only failures. It also checks that the name it hands tosecurity cms -Nbelongs to exactly one certificate in the keychain it was pointed at, and refuses otherwise. That check is about identifying the signer, not about judging it:-Nselects by name alone and takes no fingerprint, so with two certificates of the same name the tool could not say afterwards which one signed. Whethersecurity cmsrestricts itself to the keychain given with--keychainis not measured here and not claimed. - No policy review. Schema valid means keys and types match Apple's YAML. It says nothing about whether a device accepts the profile or whether the policy behind it makes sense.
- Unsigned profiles are for lab use. Do not deploy them to production or through an MDM.
README.md:88andreferences/signing.md:3say the same, nothing in the code enforces it:build_mobileconfig.py:373-375prints a hint and exits 0. - No guarantee about Apple's schema. Whatever
apple/device-managementpublishes is used as is, including its errors. - No SLA. See the reporting section for what a response actually means here.