How to create an encrypted archive as a self-extracting script¶
I wanted to put files on a USB stick so that they were encrypted, but could still be opened on any Mac or Linux machine with nothing installed. No VeraCrypt, no admin rights, no package manager.
That turns out to be impossible in the form people usually ask for it, and quite easy in a slightly different form. Both halves are worth knowing.
Why the obvious answers don't work¶
"A password-protected ISO." There is no such thing. ISO 9660 has no encryption anywhere in the format. What people mean is an encrypted disk image, and those are platform-specific: DMG on macOS, LUKS on Linux, VeraCrypt containers for both.
"Just embed the unlocking program." A single executable cannot run on both — macOS uses Mach-O binaries, Linux uses ELF. The only things that run on both without translation are shell scripts.
"Mount it like a drive." Mounting an encrypted volume needs a filesystem driver and root. On macOS that means installing macFUSE. So "mountable" and "nothing installed" are mutually exclusive on macOS — that is a fact about the platform, not a gap in the tooling.
What is possible: a single file that is half shell script and half ciphertext, where the script knows how to decrypt the rest of itself using tools both systems already ship.
The trick¶
Concatenate a shell script and an encrypted blob into one file:
bytes 1 … 1171 readable shell script ← the opener
bytes 1172 … 202483 AES-256 ciphertext ← the payload
Two conveniences make this work:
- The shell stops reading at
exit 0. It never parses the binary garbage that follows, so the file is a perfectly valid script that happens to have 200 KB stapled to its end. - A script can read itself.
$0is the path to the running file, so it doestail -c +1172 "$0"— skip its own source, pipe the rest intoopenssl enc -d.
Dumping the boundary shows it plainly — the script ends and OpenSSL's own Salted__ marker begins immediately after:
00000010: 4553 5422 203e 2632 0a65 7869 7420 300a EST" >&2.exit 0.
00000020: 5361 6c74 6564 5f5f 3cb7 e383 98ae b381 Salted__<.......
Two bugs worth knowing about¶
The script has to state, inside itself, exactly how long it is. That is circular, and it bites twice.
Leading zeros mean octal. I padded the offset to a fixed width with printf '%010d', producing OFF=0000001171. In POSIX arithmetic a leading zero means base 8, so $((0000001171 + 1)) evaluates to 601, not 1172. It pointed ~570 bytes into the wrong place and nothing would decrypt.
Writing the number changes the number. Substituting the real offset into the header changes the header's length — which is the very value being substituted. It has to sit in a fixed-width field so the length is stable across the substitution.
The fix for both: pad on the right with a comment, so the number itself has no leading zeros and the field width never moves.
What it protects, and what it doesn't¶
It does keep contents from being read: AES-256-CBC with PBKDF2-SHA256 at 600 000 iterations and a random salt. That part is sound.
It does not properly protect against deliberate alteration. Damage and casual tampering are caught by a SHA-256 checksum, but an attacker who can rewrite the file can recompute that checksum. Real authenticated encryption needs an AEAD mode, and openssl enc cannot do AEAD at all.
If tamper-resistance matters, carry age binaries for macOS and Linux in a plain folder instead — still nothing to install, and proper authenticated encryption.
It encrypts a copy, not in place¶
Worth being explicit, because the assumption runs the other way: this reads the original and writes a new encrypted file alongside it. The plaintext is still sitting there afterwards, and deleting it is a separate, deliberate act.
That is the opposite of LUKS or FileVault, where the volume itself becomes encrypted and no plaintext copy exists anywhere. It also means you need free space for both copies — fine for a few GB, wrong for 200 GB.
Choosing the right tool instead¶
| Where it must open | Use |
|---|---|
| Linux only | LUKS2 (cryptsetup) — kernel-native, nothing to install |
| macOS only | Encrypted APFS via Disk Utility |
| Windows + macOS + Linux, mounted | VeraCrypt (needs macFUSE on macOS) |
| Anywhere, nothing installed | A self-extracting script — this page |
| Syncing to cloud/NAS | Cryptomator or gocryptfs — file-level, syncs incrementally |
| Backups | age, or restic/borg for dedupe and versioning |
The script¶
sh mkselfcrypt.sh ~/Documents/private vault.sh # asks for a passphrase
sh vault.sh /some/destination # asks again, unlocks
sh vault.sh --help # says what it is
The archive it produces explains itself, which is the point — a stray vault.sh on a USB stick is otherwise completely opaque:
vault.sh — a password-protected archive.
This file is not a document. It holds encrypted contents plus the code needed
to unlock them, so it opens on any Mac or Linux machine with nothing installed.
YOU NEED THE PASSPHRASE. Without it the contents cannot be recovered — there is
no reset and no recovery. Whoever made this file has it.
Full source:
#!/bin/sh
# mkselfcrypt.sh — pack a file or folder into a self-decrypting archive.
# Run with --help for usage.
set -eu
ITER=600000
usage() {
cat <<'USAGEEOF'
mkselfcrypt.sh — make a password-protected, self-extracting archive.
USAGE
sh mkselfcrypt.sh <file-or-folder> <output.sh>
sh mkselfcrypt.sh --help
It asks for a passphrase, then writes <output.sh>: one file holding both your
encrypted contents and the code to unlock them. Open it anywhere with:
sh <output.sh> [destination-folder]
The output needs nothing installed — only tools macOS and Linux already ship.
NOTES
The original is NOT touched. This writes an encrypted copy; the plaintext
is still there afterwards, and deleting it is up to you.
Run `sh <output.sh> --help` for what the archive itself will tell you.
USAGEEOF
}
case "${1:-}" in
-h|--help|"") usage; exit 0 ;;
esac
SRC=$1
OUT=${2:-}
[ -n "$OUT" ] || { usage >&2; exit 2; }
[ -e "$SRC" ] || { echo "no such source: $SRC" >&2; exit 1; }
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT INT TERM
BASE=$(basename "$SRC")
DIR=$(cd "$(dirname "$SRC")" && pwd)
echo "packing $BASE ..." >&2
tar -czf "$TMP/plain.tgz" -C "$DIR" "$BASE"
SUM=$(openssl dgst -sha256 "$TMP/plain.tgz" | awk '{print $NF}')
printf 'passphrase: ' >&2
stty -echo 2>/dev/null || true
read -r PW
stty echo 2>/dev/null || true
printf '\nconfirm: ' >&2
stty -echo 2>/dev/null || true
read -r PW2
stty echo 2>/dev/null || true
printf '\n' >&2
[ "$PW" = "$PW2" ] || { echo "passphrases differ" >&2; exit 1; }
[ -n "$PW" ] || { echo "empty passphrase refused" >&2; exit 1; }
SFX_PW=$PW; export SFX_PW
openssl enc -aes-256-cbc -md sha256 -pbkdf2 -iter "$ITER" -salt \
-in "$TMP/plain.tgz" -out "$TMP/cipher.bin" -pass env:SFX_PW
unset SFX_PW
# The header records where it ends and the ciphertext begins. Substituting that
# number changes the header's length, so it goes in a fixed-width 16-char field
# padded on the RIGHT with a comment — the length stays put, and the number
# keeps no leading zeros (a zero-padded literal is read as OCTAL by $(( )) ).
cat >"$TMP/header" <<HEADEREOF
#!/bin/sh
# ENCRYPTED ARCHIVE — run \`sh \$(basename \$0) --help\` for what this is.
set -eu
OFF=__OFFSET_FIELD__
SUM=$SUM
ITER=$ITER
ME=\$(basename "\$0")
case "\${1:-}" in
-h|--help)
cat <<HELPEOF
\$ME — a password-protected archive.
This file is not a document. It holds encrypted contents plus the code needed
to unlock them, so it opens on any Mac or Linux machine with nothing installed.
YOU NEED THE PASSPHRASE. Without it the contents cannot be recovered — there is
no reset and no recovery. Whoever made this file has it.
USAGE
sh \$ME unlock into the current folder
sh \$ME <destination> unlock into that folder
You will be prompted for the passphrase; it will not echo as you type.
The archive is left intact — unlocking writes a decrypted copy elsewhere.
AES-256-CBC, PBKDF2-SHA256, \$ITER iterations.
If macOS refuses to run this after a download or AirDrop:
xattr -d com.apple.quarantine \$ME
HELPEOF
exit 0 ;;
esac
SELF=\$0
case \$SELF in /*) ;; *) SELF=\$PWD/\$SELF ;; esac
command -v openssl >/dev/null 2>&1 || { echo "openssl not found" >&2; exit 1; }
DEST=\${1:-.}
[ -d "\$DEST" ] || { echo "not a directory: \$DEST" >&2; exit 1; }
TMP=\$(mktemp -d)
trap 'rm -rf "\$TMP"' EXIT INT TERM
printf 'passphrase: ' >&2
stty -echo 2>/dev/null || true
read -r PW
stty echo 2>/dev/null || true
printf '\n' >&2
SFX_PW=\$PW; export SFX_PW
if ! tail -c +\$((OFF + 1)) "\$SELF" | openssl enc -d -aes-256-cbc -md sha256 \\
-pbkdf2 -iter "\$ITER" -pass env:SFX_PW >"\$TMP/plain.tgz" 2>/dev/null; then
unset SFX_PW
echo "decryption failed — wrong passphrase, or the file is damaged" >&2
exit 1
fi
unset SFX_PW
GOT=\$(openssl dgst -sha256 "\$TMP/plain.tgz" | awk '{print \$NF}')
[ "\$GOT" = "\$SUM" ] || { echo "checksum mismatch — archive is corrupt" >&2; exit 1; }
tar -xzf "\$TMP/plain.tgz" -C "\$DEST"
echo "unlocked into \$DEST" >&2
exit 0
HEADEREOF
LEN=$(wc -c <"$TMP/header" | tr -d ' ')
FIELD=$(awk -v n="$LEN" 'BEGIN{ s = n " #"; while (length(s) < 16) s = s "#"; print s }')
[ "${#FIELD}" -eq 16 ] || { echo "internal: offset too large to encode" >&2; exit 1; }
sed "s/__OFFSET_FIELD__/$FIELD/" "$TMP/header" >"$TMP/header.final"
LEN2=$(wc -c <"$TMP/header.final" | tr -d ' ')
[ "$LEN" = "$LEN2" ] || { echo "internal: header length drifted" >&2; exit 1; }
cat "$TMP/header.final" "$TMP/cipher.bin" >"$OUT"
# Deliberately NOT chmod +x: the output is meant to be run as `sh <file>`.
# Making it executable here also matches a macOS XProtect malware signature
# for droppers that assemble an executable at runtime — which gets THIS
# script silently deleted by the OS.
echo "wrote $OUT ($(wc -c <"$OUT" | tr -d ' ') bytes)" >&2
echo "open it with: sh $OUT [destination] (--help for details)" >&2
macOS deleted the script as malware¶
Running the generator by double-clicking it, or as ./mkselfcrypt.sh, produced "Malware Blocked and Moved to Trash" — and macOS deleted the file. Not a warning: the file was gone.
This is XProtect, and it is not a quarantine problem. The file had no com.apple.quarantine attribute at all, so xattr -d is irrelevant here. XProtect matched on the script's contents.
That is fair, in a way. A script that reads its own tail, decrypts it with openssl, untars the result into a temp folder and marks something executable is, structurally, exactly a Shlayer-style dropper. The technique and the malware are the same shape.
Finding the trigger¶
Bisecting the script by prefix — write the first N lines, chmod +x, run, see whether the OS eats it — put the boundary between lines 147 and 149:
Line 148 was:
Removing that one line cleared the detection entirely. It is a multi-condition signature, not a single-line rule: minimal scripts containing cat a b > out plus chmod +x out, or tail -c +N "$0" | openssl enc -d | tar -xz, all ran fine on their own. The rule only fires when the whole set of dropper ingredients co-occurs, and chmod +x on a runtime-assembled file was the last ingredient.
What this means in practice¶
- Don't
chmod +xa file your script just assembled. That is the part that reads as malicious. The generator no longer does it. sh script.shis never blocked;./script.shis. Passing the file as an argument to an already-trusted interpreter means the kernel never evaluates the script itself as an executable, soAppleSystemPolicynever runs. Direct execution via the shebang is what triggers the check. This is why the documented usage issh mkselfcrypt.sh.- The generated archives were never flagged, before or after the fix — which is the case that matters, since archives are what get shared.
- The log line to look for, if a script vanishes on you:
log show --last 30m --predicate 'eventMessage CONTAINS[c] "yourscript"' --style compact
# ASP: Security policy would not allow process: 68141, /path/to/yourscript.sh
Worth knowing generally: a shell script can be silently deleted by macOS for what it contains, with no prompt and no undo, and the copy in your editor is not safe either if you run it from there.
Gotchas¶
- Always run these as
sh file.sh, never./file.sh. Direct execution invokes macOS's executable policy check; passing the file toshdoes not. See the XProtect section above. - macOS quarantine. A script that arrives via browser or AirDrop is flagged. Clear it with
xattr -d com.apple.quarantine vault.sh. This is a separate mechanism from the XProtect content scan above — clearing quarantine does nothing for that one. - AppleDouble litter. Copying to a FAT volume on macOS silently creates
._sidecar files.dot_clean -m /Volumes/NAMEremoves them. - FAT32 caps files at 4 GB, so nothing you pack can exceed that. Use exFAT if you need more — also readable by both platforms with nothing installed.
- Test from the medium, not just from your laptop. A script that works locally and fails from the stick is a bad thing to discover later.
- Apple's stock
/usr/bin/opensslis LibreSSL 3.3.6, not OpenSSL. It does support-pbkdf2, which is the flag this depends on — worth verifying rather than assuming.