Skip to content

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:

  1. 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.
  2. A script can read itself. $0 is the path to the running file, so it does tail -c +1172 "$0" — skip its own source, pipe the rest into openssl 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.

OFF=1171 ###########

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

Download mkselfcrypt.sh

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:

mkselfcrypt.sh
#!/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:

first 147 lines: survived
first 149 lines: >>> DELETED <<<

Line 148 was:

chmod +x "$OUT" 2>/dev/null || true

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 +x a file your script just assembled. That is the part that reads as malicious. The generator no longer does it.
  • sh script.sh is never blocked; ./script.sh is. Passing the file as an argument to an already-trusted interpreter means the kernel never evaluates the script itself as an executable, so AppleSystemPolicy never runs. Direct execution via the shebang is what triggers the check. This is why the documented usage is sh 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 to sh does 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/NAME removes 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/openssl is LibreSSL 3.3.6, not OpenSSL. It does support -pbkdf2, which is the flag this depends on — worth verifying rather than assuming.