diff --git a/apksigner/.gitignore b/apksigner/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/apksigner/.gitignore @@ -0,0 +1 @@ +/build diff --git a/apksigner/build.gradle b/apksigner/build.gradle new file mode 100644 index 0000000..e13b482 --- /dev/null +++ b/apksigner/build.gradle @@ -0,0 +1,11 @@ +apply plugin: 'java-library' + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + + implementation 'commons-cli:commons-cli:1.4' + implementation 'com.madgag.spongycastle:bcpkix-jdk15on:1.56.0.0' +} + +sourceCompatibility = "8" +targetCompatibility = "8" diff --git a/apksigner/src/main/java/net/fornwall/apksigner/Base64.java b/apksigner/src/main/java/net/fornwall/apksigner/Base64.java new file mode 100644 index 0000000..cc51c04 --- /dev/null +++ b/apksigner/src/main/java/net/fornwall/apksigner/Base64.java @@ -0,0 +1,21 @@ +package net.fornwall.apksigner; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import org.spongycastle.util.encoders.Base64Encoder; + +/** Base64 encoding handling in a portable way across Android and JSE. */ +public class Base64 { + + public static String encode(byte[] data) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + new Base64Encoder().encode(data, 0, data.length, baos); + } catch (IOException e) { + throw new RuntimeException(e); + } + return new String(baos.toByteArray()); + } + +} \ No newline at end of file diff --git a/apksigner/src/main/java/net/fornwall/apksigner/CertCreator.java b/apksigner/src/main/java/net/fornwall/apksigner/CertCreator.java new file mode 100644 index 0000000..7dd1c47 --- /dev/null +++ b/apksigner/src/main/java/net/fornwall/apksigner/CertCreator.java @@ -0,0 +1,158 @@ +package net.fornwall.apksigner; + +import java.io.File; +import java.io.IOException; +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Vector; + +import org.spongycastle.asn1.ASN1ObjectIdentifier; +import org.spongycastle.asn1.x500.style.BCStyle; +import org.spongycastle.jce.X509Principal; +import org.spongycastle.x509.X509V3CertificateGenerator; + +/** All methods create self-signed certificates. */ +public class CertCreator { + + /** Helper class for dealing with the distinguished name RDNs. */ + @SuppressWarnings("serial") + public static class DistinguishedNameValues extends LinkedHashMap { + + public DistinguishedNameValues() { + put(BCStyle.C, null); + put(BCStyle.ST, null); + put(BCStyle.L, null); + put(BCStyle.STREET, null); + put(BCStyle.O, null); + put(BCStyle.OU, null); + put(BCStyle.CN, null); + } + + @Override + public String put(ASN1ObjectIdentifier oid, String value) { + if (value != null && value.equals("")) + value = null; + if (containsKey(oid)) + super.put(oid, value); // preserve original ordering + else { + super.put(oid, value); + // String cn = remove(BCStyle.CN); // CN will always be last. + // put(BCStyle.CN,cn); + } + return value; + } + + public void setCountry(String country) { + put(BCStyle.C, country); + } + + public void setState(String state) { + put(BCStyle.ST, state); + } + + public void setLocality(String locality) { + put(BCStyle.L, locality); + } + + public void setStreet(String street) { + put(BCStyle.STREET, street); + } + + public void setOrganization(String organization) { + put(BCStyle.O, organization); + } + + public void setOrganizationalUnit(String organizationalUnit) { + put(BCStyle.OU, organizationalUnit); + } + + public void setCommonName(String commonName) { + put(BCStyle.CN, commonName); + } + + @Override + public int size() { + int result = 0; + for (String value : values()) { + if (value != null) + result += 1; + } + return result; + } + + public X509Principal getPrincipal() { + Vector oids = new Vector<>(); + Vector values = new Vector<>(); + for (Map.Entry entry : entrySet()) { + if (entry.getValue() != null && !entry.getValue().equals("")) { + oids.add(entry.getKey()); + values.add(entry.getValue()); + } + } + return new X509Principal(oids, values); + } + } + + public static KeySet createKeystoreAndKey(String storePath, char[] storePass, String keyAlgorithm, int keySize, + String keyName, char[] keyPass, String certSignatureAlgorithm, int certValidityYears, + DistinguishedNameValues distinguishedNameValues) { + try { + KeySet keySet = createKey(keyAlgorithm, keySize, certSignatureAlgorithm, certValidityYears, + distinguishedNameValues); + + KeyStore privateKS = KeyStoreFileManager.createKeyStore(storePass); + privateKS.setKeyEntry(keyName, keySet.privateKey, keyPass, + new java.security.cert.Certificate[] { keySet.publicKey }); + + File sfile = new File(storePath); + if (sfile.exists()) { + throw new IOException("File already exists: " + storePath); + } + KeyStoreFileManager.writeKeyStore(privateKS, storePath, storePass); + + return keySet; + } catch (RuntimeException x) { + throw x; + } catch (Exception x) { + throw new RuntimeException(x.getMessage(), x); + } + } + + private static KeySet createKey(String keyAlgorithm, int keySize, String certSignatureAlgorithm, + int certValidityYears, DistinguishedNameValues distinguishedNameValues) { + try { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(keyAlgorithm); + keyPairGenerator.initialize(keySize); + KeyPair KPair = keyPairGenerator.generateKeyPair(); + + X509V3CertificateGenerator v3CertGen = new X509V3CertificateGenerator(); + X509Principal principal = distinguishedNameValues.getPrincipal(); + + // generate a positive serial number + BigInteger serialNumber = BigInteger.valueOf(new SecureRandom().nextInt()); + while (serialNumber.compareTo(BigInteger.ZERO) < 0) + serialNumber = BigInteger.valueOf(new SecureRandom().nextInt()); + v3CertGen.setSerialNumber(serialNumber); + v3CertGen.setIssuerDN(principal); + v3CertGen.setNotBefore(new Date(System.currentTimeMillis() - 1000L * 60L * 60L * 24L * 30L)); + v3CertGen.setNotAfter( + new Date(System.currentTimeMillis() + (1000L * 60L * 60L * 24L * 366L * certValidityYears))); + v3CertGen.setSubjectDN(principal); + v3CertGen.setPublicKey(KPair.getPublic()); + v3CertGen.setSignatureAlgorithm(certSignatureAlgorithm); + + X509Certificate PKCertificate = v3CertGen.generate(KPair.getPrivate(), + KeyStoreFileManager.SECURITY_PROVIDER.getName()); + return new KeySet(PKCertificate, KPair.getPrivate(), null); + } catch (Exception x) { + throw new RuntimeException(x.getMessage(), x); + } + } +} diff --git a/apksigner/src/main/java/net/fornwall/apksigner/JKS.java b/apksigner/src/main/java/net/fornwall/apksigner/JKS.java new file mode 100644 index 0000000..64f4352 --- /dev/null +++ b/apksigner/src/main/java/net/fornwall/apksigner/JKS.java @@ -0,0 +1,478 @@ +/* JKS.java -- implementation of the "JKS" key store. + Copyright (C) 2003 Casey Marshall + +Permission to use, copy, modify, distribute, and sell this software and +its documentation for any purpose is hereby granted without fee, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation. No representations are made about the +suitability of this software for any purpose. It is provided "as is" +without express or implied warranty. + +This program was derived by reverse-engineering Sun's own +implementation, using only the public API that is available in the 1.4.1 +JDK. Hence nothing in this program is, or is derived from, anything +copyrighted by Sun Microsystems. While the "Binary Evaluation License +Agreement" that the JDK is licensed under contains blanket statements +that forbid reverse-engineering (among other things), it is my position +that US copyright law does not and cannot forbid reverse-engineering of +software to produce a compatible implementation. There are, in fact, +numerous clauses in copyright law that specifically allow +reverse-engineering, and therefore I believe it is outside of Sun's +power to enforce restrictions on reverse-engineering of their software, +and it is irresponsible for them to claim they can. */ + +package net.fornwall.apksigner; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.security.DigestInputStream; +import java.security.DigestOutputStream; +import java.security.Key; +import java.security.KeyFactory; +import java.security.KeyStoreException; +import java.security.KeyStoreSpi; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.UnrecoverableKeyException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Date; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Vector; + +import javax.crypto.EncryptedPrivateKeyInfo; +import javax.crypto.spec.SecretKeySpec; + +/** + * This is an implementation of Sun's proprietary key store algorithm, called "JKS" for "Java Key Store". This + * implementation was created entirely through reverse-engineering. + * + *

+ * The format of JKS files is, from the start of the file: + * + *

    + *
  1. Magic bytes. This is a four-byte integer, in big-endian byte order, equal to 0xFEEDFEED.
  2. + *
  3. The version number (probably), as a four-byte integer (all multibyte integral types are in big-endian byte + * order). The current version number (in modern distributions of the JDK) is 2.
  4. + *
  5. The number of entries in this keystore, as a four-byte integer. Call this value n
  6. + *
  7. Then, n times: + *
      + *
    1. The entry type, a four-byte int. The value 1 denotes a private key entry, and 2 denotes a trusted certificate.
    2. + *
    3. The entry's alias, formatted as strings such as those written by DataOutput.writeUTF(String).
    4. + *
    5. An eight-byte integer, representing the entry's creation date, in milliseconds since the epoch. + * + *

      + * Then, if the entry is a private key entry: + *

        + *
      1. The size of the encoded key as a four-byte int, then that number of bytes. The encoded key is the DER encoded + * bytes of the EncryptedPrivateKeyInfo + * structure (the encryption algorithm is discussed later).
      2. + *
      3. A four-byte integer, followed by that many encoded certificates, encoded as described in the trusted certificates + * section.
      4. + *
      + * + *

      + * Otherwise, the entry is a trusted certificate, which is encoded as the name of the encoding algorithm (e.g. X.509), + * encoded the same way as alias names. Then, a four-byte integer representing the size of the encoded certificate, then + * that many bytes representing the encoded certificate (e.g. the DER bytes in the case of X.509).

    6. + *
    + *
  8. + *
  9. Then, the signature.
  10. + *
+ * + * + *

+ * (See this file for some idea of how I was able to figure out these algorithms) + *

+ * + *

+ * Decrypting the key works as follows: + * + *

    + *
  1. The key length is the length of the ciphertext minus 40. The encrypted key, ekey, is the middle + * bytes of the ciphertext.
  2. + *
  3. Take the first 20 bytes of the encrypted key as a seed value, K[0].
  4. + *
  5. Compute K[1] ... K[n], where |K[i]| = 20, n = ceil(|ekey| / 20), and + * K[i] = SHA-1(UTF-16BE(password) + K[i-1]).
  6. + *
  7. key = ekey ^ (K[1] + ... + K[n]).
  8. + *
  9. The last 20 bytes are the checksum, computed as H = + * SHA-1(UTF-16BE(password) + key). If this value does not match the last 20 bytes of the ciphertext, output + * FAIL. Otherwise, output key.
  10. + *
+ * + *

+ * The signature is defined as SHA-1(UTF-16BE(password) + + * US_ASCII("Mighty Aphrodite") + encoded_keystore) (yup, Sun engineers are just that clever). + * + *

+ * (Above, SHA-1 denotes the secure hash algorithm, UTF-16BE the big-endian byte representation of a UTF-16 string, and + * US_ASCII the ASCII byte representation of the string.) + * + *

+ * The original source code by Casey Marshall of this class should be available in the file http://metastatic.org/source/JKS.java. + * + *

+ * Changes by Ken Ellinwood: + *

    + *
  • Fixed a NullPointerException in engineLoad(). This method must return gracefully if the keystore input stream is + * null.
  • + *
  • engineGetCertificateEntry() was updated to return the first cert in the chain for private key entries.
  • + *
  • Lowercase the alias names, otherwise keytool chokes on the file created by this code.
  • + *
  • Fixed the integrity check in engineLoad(), previously the exception was never thrown regardless of password + * value.
  • + *
+ * + * @author Casey Marshall (rsdio@metastatic.org) + * @author Ken Ellinwood + */ +public class JKS extends KeyStoreSpi { + + /** Ah, Sun. So goddamned clever with those magic bytes. */ + private static final int MAGIC = 0xFEEDFEED; + + private static final int PRIVATE_KEY = 1; + private static final int TRUSTED_CERT = 2; + + private final Vector aliases = new Vector<>(); + private final HashMap trustedCerts = new HashMap<>(); + private final HashMap privateKeys = new HashMap<>(); + private final HashMap certChains = new HashMap<>(); + private final HashMap dates = new HashMap<>(); + + @Override + public Key engineGetKey(String alias, char[] password) throws NoSuchAlgorithmException, UnrecoverableKeyException { + alias = alias.toLowerCase(); + + if (!privateKeys.containsKey(alias)) + return null; + byte[] key = decryptKey(privateKeys.get(alias), charsToBytes(password)); + Certificate[] chain = engineGetCertificateChain(alias); + if (chain.length > 0) { + try { + // Private and public keys MUST have the same algorithm. + KeyFactory fact = KeyFactory.getInstance(chain[0].getPublicKey().getAlgorithm()); + return fact.generatePrivate(new PKCS8EncodedKeySpec(key)); + } catch (InvalidKeySpecException x) { + throw new UnrecoverableKeyException(x.getMessage()); + } + } else + return new SecretKeySpec(key, alias); + } + + @Override + public Certificate[] engineGetCertificateChain(String alias) { + return certChains.get(alias.toLowerCase()); + } + + @Override + public Certificate engineGetCertificate(String alias) { + alias = alias.toLowerCase(); + if (engineIsKeyEntry(alias)) { + Certificate[] certChain = certChains.get(alias); + if (certChain != null && certChain.length > 0) + return certChain[0]; + } + return trustedCerts.get(alias); + } + + @Override + public Date engineGetCreationDate(String alias) { + alias = alias.toLowerCase(); + return dates.get(alias); + } + + // XXX implement writing methods. + @Override + public void engineSetKeyEntry(String alias, Key key, char[] passwd, Certificate[] certChain) + throws KeyStoreException { + alias = alias.toLowerCase(); + if (trustedCerts.containsKey(alias)) + throw new KeyStoreException("\"" + alias + " is a trusted certificate entry"); + privateKeys.put(alias, encryptKey(key, charsToBytes(passwd))); + if (certChain != null) + certChains.put(alias, certChain); + else + certChains.put(alias, new Certificate[0]); + if (!aliases.contains(alias)) { + dates.put(alias, new Date()); + aliases.add(alias); + } + } + + @SuppressWarnings("unused") + @Override + public void engineSetKeyEntry(String alias, byte[] encodedKey, Certificate[] certChain) throws KeyStoreException { + alias = alias.toLowerCase(); + if (trustedCerts.containsKey(alias)) + throw new KeyStoreException("\"" + alias + "\" is a trusted certificate entry"); + try { + new EncryptedPrivateKeyInfo(encodedKey); + } catch (IOException ioe) { + throw new KeyStoreException("encoded key is not an EncryptedPrivateKeyInfo"); + } + privateKeys.put(alias, encodedKey); + if (certChain != null) + certChains.put(alias, certChain); + else + certChains.put(alias, new Certificate[0]); + if (!aliases.contains(alias)) { + dates.put(alias, new Date()); + aliases.add(alias); + } + } + + @Override + public void engineSetCertificateEntry(String alias, Certificate cert) throws KeyStoreException { + alias = alias.toLowerCase(); + if (privateKeys.containsKey(alias)) + throw new KeyStoreException("\"" + alias + "\" is a private key entry"); + if (cert == null) + throw new NullPointerException(); + trustedCerts.put(alias, cert); + if (!aliases.contains(alias)) { + dates.put(alias, new Date()); + aliases.add(alias); + } + } + + @Override + public void engineDeleteEntry(String alias) throws KeyStoreException { + alias = alias.toLowerCase(); + aliases.remove(alias); + } + + @Override + public Enumeration engineAliases() { + return aliases.elements(); + } + + @Override + public boolean engineContainsAlias(String alias) { + alias = alias.toLowerCase(); + return aliases.contains(alias); + } + + @Override + public int engineSize() { + return aliases.size(); + } + + @Override + public boolean engineIsKeyEntry(String alias) { + alias = alias.toLowerCase(); + return privateKeys.containsKey(alias); + } + + @Override + public boolean engineIsCertificateEntry(String alias) { + alias = alias.toLowerCase(); + return trustedCerts.containsKey(alias); + } + + @Override + public String engineGetCertificateAlias(Certificate cert) { + for (String alias : trustedCerts.keySet()) + if (cert.equals(trustedCerts.get(alias))) + return alias; + return null; + } + + @Override + public void engineStore(OutputStream out, char[] passwd) throws IOException, NoSuchAlgorithmException, + CertificateException { + MessageDigest md = MessageDigest.getInstance("SHA1"); + md.update(charsToBytes(passwd)); + md.update("Mighty Aphrodite".getBytes(StandardCharsets.UTF_8)); + DataOutputStream dout = new DataOutputStream(new DigestOutputStream(out, md)); + dout.writeInt(MAGIC); + dout.writeInt(2); + dout.writeInt(aliases.size()); + for (Enumeration e = aliases.elements(); e.hasMoreElements();) { + String alias = e.nextElement(); + if (trustedCerts.containsKey(alias)) { + dout.writeInt(TRUSTED_CERT); + dout.writeUTF(alias); + dout.writeLong(dates.get(alias).getTime()); + writeCert(dout, trustedCerts.get(alias)); + } else { + dout.writeInt(PRIVATE_KEY); + dout.writeUTF(alias); + dout.writeLong(dates.get(alias).getTime()); + byte[] key = privateKeys.get(alias); + dout.writeInt(key.length); + dout.write(key); + Certificate[] chain = certChains.get(alias); + dout.writeInt(chain.length); + for (int i = 0; i < chain.length; i++) + writeCert(dout, chain[i]); + } + } + byte[] digest = md.digest(); + dout.write(digest); + } + + @Override + public void engineLoad(InputStream in, char[] passwd) throws IOException, NoSuchAlgorithmException, + CertificateException { + MessageDigest md = MessageDigest.getInstance("SHA"); + if (passwd != null) + md.update(charsToBytes(passwd)); + md.update("Mighty Aphrodite".getBytes(StandardCharsets.UTF_8)); + + aliases.clear(); + trustedCerts.clear(); + privateKeys.clear(); + certChains.clear(); + dates.clear(); + if (in == null) + return; + DataInputStream din = new DataInputStream(new DigestInputStream(in, md)); + if (din.readInt() != MAGIC) + throw new IOException("not a JavaKeyStore"); + din.readInt(); // version no. + final int n = din.readInt(); + aliases.ensureCapacity(n); + if (n < 0) + throw new LoadKeystoreException("Malformed key store"); + for (int i = 0; i < n; i++) { + int type = din.readInt(); + String alias = din.readUTF(); + aliases.add(alias); + dates.put(alias, new Date(din.readLong())); + switch (type) { + case PRIVATE_KEY: + int len = din.readInt(); + byte[] encoded = new byte[len]; + din.read(encoded); + privateKeys.put(alias, encoded); + int count = din.readInt(); + Certificate[] chain = new Certificate[count]; + for (int j = 0; j < count; j++) + chain[j] = readCert(din); + certChains.put(alias, chain); + break; + + case TRUSTED_CERT: + trustedCerts.put(alias, readCert(din)); + break; + + default: + throw new LoadKeystoreException("Malformed key store"); + } + } + + if (passwd != null) { + byte[] computedHash = md.digest(); + byte[] storedHash = new byte[20]; + din.read(storedHash); + if (!MessageDigest.isEqual(storedHash, computedHash)) { + throw new LoadKeystoreException("Incorrect password, or integrity check failed."); + } + } + } + + // Own methods. + // ------------------------------------------------------------------------ + + private static Certificate readCert(DataInputStream in) throws IOException, CertificateException { + String type = in.readUTF(); + int len = in.readInt(); + byte[] encoded = new byte[len]; + in.read(encoded); + CertificateFactory factory = CertificateFactory.getInstance(type); + return factory.generateCertificate(new ByteArrayInputStream(encoded)); + } + + private static void writeCert(DataOutputStream dout, Certificate cert) throws IOException, CertificateException { + dout.writeUTF(cert.getType()); + byte[] b = cert.getEncoded(); + dout.writeInt(b.length); + dout.write(b); + } + + private static byte[] decryptKey(byte[] encryptedPKI, byte[] passwd) throws UnrecoverableKeyException { + try { + EncryptedPrivateKeyInfo epki = new EncryptedPrivateKeyInfo(encryptedPKI); + byte[] encr = epki.getEncryptedData(); + byte[] keystream = new byte[20]; + System.arraycopy(encr, 0, keystream, 0, 20); + byte[] check = new byte[20]; + System.arraycopy(encr, encr.length - 20, check, 0, 20); + byte[] key = new byte[encr.length - 40]; + MessageDigest sha = MessageDigest.getInstance("SHA1"); + int count = 0; + while (count < key.length) { + sha.reset(); + sha.update(passwd); + sha.update(keystream); + sha.digest(keystream, 0, keystream.length); + for (int i = 0; i < keystream.length && count < key.length; i++) { + key[count] = (byte) (keystream[i] ^ encr[count + 20]); + count++; + } + } + sha.reset(); + sha.update(passwd); + sha.update(key); + if (!MessageDigest.isEqual(check, sha.digest())) + throw new UnrecoverableKeyException("checksum mismatch"); + return key; + } catch (Exception x) { + throw new UnrecoverableKeyException(x.getMessage()); + } + } + + private static byte[] encryptKey(Key key, byte[] passwd) throws KeyStoreException { + try { + MessageDigest sha = MessageDigest.getInstance("SHA1"); + // SecureRandom rand = SecureRandom.getInstance("SHA1PRNG"); + byte[] k = key.getEncoded(); + byte[] encrypted = new byte[k.length + 40]; + byte[] keystream = SecureRandom.getSeed(20); + System.arraycopy(keystream, 0, encrypted, 0, 20); + int count = 0; + while (count < k.length) { + sha.reset(); + sha.update(passwd); + sha.update(keystream); + sha.digest(keystream, 0, keystream.length); + for (int i = 0; i < keystream.length && count < k.length; i++) { + encrypted[count + 20] = (byte) (keystream[i] ^ k[count]); + count++; + } + } + sha.reset(); + sha.update(passwd); + sha.update(k); + sha.digest(encrypted, encrypted.length - 20, 20); + // 1.3.6.1.4.1.42.2.17.1.1 is Sun's private OID for this encryption algorithm. + return new EncryptedPrivateKeyInfo("1.3.6.1.4.1.42.2.17.1.1", encrypted).getEncoded(); + } catch (Exception x) { + throw new KeyStoreException(x.getMessage()); + } + } + + private static byte[] charsToBytes(char[] passwd) { + byte[] buf = new byte[passwd.length * 2]; + for (int i = 0, j = 0; i < passwd.length; i++) { + buf[j++] = (byte) (passwd[i] >>> 8); + buf[j++] = (byte) passwd[i]; + } + return buf; + } +} diff --git a/apksigner/src/main/java/net/fornwall/apksigner/KeySet.java b/apksigner/src/main/java/net/fornwall/apksigner/KeySet.java new file mode 100644 index 0000000..ec32b71 --- /dev/null +++ b/apksigner/src/main/java/net/fornwall/apksigner/KeySet.java @@ -0,0 +1,20 @@ +package net.fornwall.apksigner; + +import java.security.PrivateKey; +import java.security.cert.X509Certificate; + +public class KeySet { + + /** Certificate. */ + public final X509Certificate publicKey; + /** Private key. */ + public final PrivateKey privateKey; + public final String signatureAlgorithm; + + public KeySet(X509Certificate publicKey, PrivateKey privateKey, String signatureAlgorithm) { + this.publicKey = publicKey; + this.privateKey = privateKey; + this.signatureAlgorithm = (signatureAlgorithm != null) ? signatureAlgorithm : "SHA1withRSA"; + } + +} diff --git a/apksigner/src/main/java/net/fornwall/apksigner/KeyStoreFileManager.java b/apksigner/src/main/java/net/fornwall/apksigner/KeyStoreFileManager.java new file mode 100644 index 0000000..a226adc --- /dev/null +++ b/apksigner/src/main/java/net/fornwall/apksigner/KeyStoreFileManager.java @@ -0,0 +1,104 @@ +package net.fornwall.apksigner; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.security.KeyStore; +import java.security.Provider; +import java.security.Security; + +import org.spongycastle.jce.provider.BouncyCastleProvider; + +public class KeyStoreFileManager { + + public static final Provider SECURITY_PROVIDER = new BouncyCastleProvider(); + + static { + // Add the spongycastle version of the BC provider so that the implementation classes returned from the keystore + // are all from the spongycastle libs. + Security.addProvider(SECURITY_PROVIDER); + } + + private static class JksKeyStore extends KeyStore { + public JksKeyStore() { + super(new JKS(), SECURITY_PROVIDER, "jks"); + } + } + + public static KeyStore createKeyStore(char[] password) throws Exception { + KeyStore ks = new JksKeyStore(); + ks.load(null, password); + return ks; + } + + public static KeyStore loadKeyStore(String keystorePath, char[] password) throws Exception { + KeyStore ks = new JksKeyStore(); + try (FileInputStream fis = new FileInputStream(keystorePath)) { + ks.load(fis, password); + } + return ks; + } + + public static void writeKeyStore(KeyStore ks, String keystorePath, char[] password) throws Exception { + File keystoreFile = new File(keystorePath); + try { + if (keystoreFile.exists()) { + // I've had some trouble saving new versions of the key store file in which the file becomes + // empty/corrupt. Saving the new version to a new file and creating a backup of the old version. + File tmpFile = File.createTempFile(keystoreFile.getName(), null, keystoreFile.getParentFile()); + try (FileOutputStream fos = new FileOutputStream(tmpFile)) { + ks.store(fos, password); + } + /* + * create a backup of the previous version int i = 1; File backup = new File( keystorePath + "." + i + + * ".bak"); while (backup.exists()) { i += 1; backup = new File( keystorePath + "." + i + ".bak"); } + * renameTo(keystoreFile, backup); + */ + renameTo(tmpFile, keystoreFile); + } else { + try (FileOutputStream fos = new FileOutputStream(keystorePath)) { + ks.store(fos, password); + } + } + } catch (Exception x) { + try { + File logfile = File.createTempFile("zipsigner-error", ".log", keystoreFile.getParentFile()); + try (PrintWriter pw = new PrintWriter(new FileWriter(logfile))) { + x.printStackTrace(pw); + } + } catch (Exception y) { + } + throw x; + } + } + + static void copyFile(File srcFile, File destFile, boolean preserveFileDate) throws IOException { + if (destFile.exists() && destFile.isDirectory()) + throw new IOException("Destination '" + destFile + "' exists but is a directory"); + try (FileInputStream input = new FileInputStream(srcFile)) { + try (FileOutputStream output = new FileOutputStream(destFile)) { + byte[] buffer = new byte[4096]; + int n = 0; + while (-1 != (n = input.read(buffer))) { + output.write(buffer, 0, n); + } + } + } + + if (srcFile.length() != destFile.length()) { + throw new IOException("Failed to copy full contents from '" + srcFile + "' to '" + destFile + "'"); + } + if (preserveFileDate) + destFile.setLastModified(srcFile.lastModified()); + } + + public static void renameTo(File fromFile, File toFile) throws IOException { + copyFile(fromFile, toFile, true); + if (!fromFile.delete()) + throw new IOException("Failed to delete " + fromFile); + } + +} diff --git a/apksigner/src/main/java/net/fornwall/apksigner/LoadKeystoreException.java b/apksigner/src/main/java/net/fornwall/apksigner/LoadKeystoreException.java new file mode 100644 index 0000000..c7cd9b3 --- /dev/null +++ b/apksigner/src/main/java/net/fornwall/apksigner/LoadKeystoreException.java @@ -0,0 +1,13 @@ +package net.fornwall.apksigner; + +import java.io.IOException; + +/** Thrown by JKS.engineLoad() for errors that occur after determining the keystore is actually a JKS keystore. */ +@SuppressWarnings("serial") +public class LoadKeystoreException extends IOException { + + public LoadKeystoreException(String message) { + super(message); + } + +} diff --git a/apksigner/src/main/java/net/fornwall/apksigner/Main.java b/apksigner/src/main/java/net/fornwall/apksigner/Main.java new file mode 100644 index 0000000..20d3236 --- /dev/null +++ b/apksigner/src/main/java/net/fornwall/apksigner/Main.java @@ -0,0 +1,97 @@ +package net.fornwall.apksigner; + +import java.io.File; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.UnrecoverableKeyException; +import java.security.cert.X509Certificate; +import java.util.List; + +import org.apache.commons.cli.BasicParser; +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.HelpFormatter; +import org.apache.commons.cli.Option; +import org.apache.commons.cli.Options; +import org.apache.commons.cli.ParseException; + +/** Sign files from the command line using zipsigner-lib. */ +public class Main { + + static void usage(Options options) { + new HelpFormatter().printHelp("apksigner [-p password] keystore input-apk output-apk", + "Sign an input APK file using the specified keystore to produce a signed and zipaligned output APK." + + " The keystore file will be created with the specified password if it does not exist. Options:", + options, ""); + System.exit(1); + } + + public static void main(String... args) throws Exception { + Options options = new Options(); + CommandLine cmdLine = null; + Option helpOption = new Option("h", "help", false, "Display usage information."); + options.addOption(helpOption); + + Option keyPasswordOption = new Option("p", "password", false, "Password for private key (default:android)."); + keyPasswordOption.setArgs(1); + options.addOption(keyPasswordOption); + + try { + cmdLine = new BasicParser().parse(options, args); + } catch (ParseException x) { + System.err.println(x.getMessage()); + usage(options); + } + + if (cmdLine.hasOption(helpOption.getOpt())) { + usage(options); + } + + @SuppressWarnings("unchecked") + List argList = cmdLine.getArgList(); + if (argList.size() != 3) { + usage(options); + } + + String keystorePath = argList.get(0); + String inputFile = argList.get(1); + String outputFile = argList.get(2); + + char[] keyPassword; + if (cmdLine.hasOption(keyPasswordOption.getOpt())) { + String optionValue = cmdLine.getOptionValue(keyPasswordOption.getOpt()); + if (optionValue == null || optionValue.equals("")) { + keyPassword = null; + } else { + keyPassword = optionValue.toCharArray(); + } + } else { + keyPassword = "android".toCharArray(); + } + + File keystoreFile = new File(keystorePath); + if (!keystoreFile.exists()) { + String alias = "alias"; + System.out.println("Creating new keystore (using '" + new String(keyPassword) + "' as password and '" + + alias + "' as the key alias)."); + CertCreator.DistinguishedNameValues nameValues = new CertCreator.DistinguishedNameValues(); + nameValues.setCommonName("APK Signer"); + nameValues.setOrganization("Earth"); + nameValues.setOrganizationalUnit("Earth"); + CertCreator.createKeystoreAndKey(keystorePath, keyPassword, "RSA", 2048, alias, keyPassword, "SHA1withRSA", + 30, nameValues); + } + + KeyStore keyStore = KeyStoreFileManager.loadKeyStore(keystorePath, null); + String alias = keyStore.aliases().nextElement(); + + X509Certificate publicKey = (X509Certificate) keyStore.getCertificate(alias); + try { + PrivateKey privateKey = (PrivateKey) keyStore.getKey(alias, keyPassword); + ZipSigner.signZip(publicKey, privateKey, "SHA1withRSA", inputFile, outputFile); + } catch (UnrecoverableKeyException e) { + System.err.println("apksigner: Invalid key password."); + System.exit(1); + } + } + +} diff --git a/apksigner/src/main/java/net/fornwall/apksigner/SignatureBlockGenerator.java b/apksigner/src/main/java/net/fornwall/apksigner/SignatureBlockGenerator.java new file mode 100644 index 0000000..c36933d --- /dev/null +++ b/apksigner/src/main/java/net/fornwall/apksigner/SignatureBlockGenerator.java @@ -0,0 +1,56 @@ +package net.fornwall.apksigner; + +import org.spongycastle.cert.jcajce.JcaCertStore; +import org.spongycastle.cms.*; +import org.spongycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder; +import org.spongycastle.operator.ContentSigner; +import org.spongycastle.operator.DigestCalculatorProvider; +import org.spongycastle.operator.jcajce.JcaContentSignerBuilder; +import org.spongycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; +import org.spongycastle.util.Store; + +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; + +public class SignatureBlockGenerator { + + /** + * Sign the given content using the private and public keys from the keySet and return the encoded CMS (PKCS#7) + * data. Use of direct signature and DER encoding produces a block that is verifiable by Android recovery programs. + */ + public static byte[] generate(KeySet keySet, byte[] content) { + try { + List certList = new ArrayList<>(); + CMSTypedData msg = new CMSProcessableByteArray(content); + + certList.add(keySet.publicKey); + + Store certs = new JcaCertStore(certList); + + CMSSignedDataGenerator gen = new CMSSignedDataGenerator(); + + JcaContentSignerBuilder jcaContentSignerBuilder = new JcaContentSignerBuilder(keySet.signatureAlgorithm) + .setProvider("SC"); + ContentSigner sha1Signer = jcaContentSignerBuilder.build(keySet.privateKey); + + JcaDigestCalculatorProviderBuilder jcaDigestCalculatorProviderBuilder = new JcaDigestCalculatorProviderBuilder() + .setProvider("SC"); + DigestCalculatorProvider digestCalculatorProvider = jcaDigestCalculatorProviderBuilder.build(); + + JcaSignerInfoGeneratorBuilder jcaSignerInfoGeneratorBuilder = new JcaSignerInfoGeneratorBuilder( + digestCalculatorProvider); + jcaSignerInfoGeneratorBuilder.setDirectSignature(true); + SignerInfoGenerator signerInfoGenerator = jcaSignerInfoGeneratorBuilder.build(sha1Signer, keySet.publicKey); + + gen.addSignerInfoGenerator(signerInfoGenerator); + gen.addCertificates(certs); + + CMSSignedData sigData = gen.generate(msg, false); + return sigData.toASN1Structure().getEncoded("DER"); + } catch (Exception x) { + throw new RuntimeException(x.getMessage(), x); + } + } + +} diff --git a/apksigner/src/main/java/net/fornwall/apksigner/ZipSigner.java b/apksigner/src/main/java/net/fornwall/apksigner/ZipSigner.java new file mode 100644 index 0000000..1a720bc --- /dev/null +++ b/apksigner/src/main/java/net/fornwall/apksigner/ZipSigner.java @@ -0,0 +1,186 @@ +package net.fornwall.apksigner; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintStream; +import java.security.DigestOutputStream; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.jar.Attributes; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +import java.util.regex.Pattern; + +import net.fornwall.apksigner.zipio.ZioEntry; +import net.fornwall.apksigner.zipio.ZipInput; +import net.fornwall.apksigner.zipio.ZipOutput; + +/** + * This is a modified copy of com.android.signapk.SignApk.java. It provides an API to sign JAR files (including APKs and + * Zip/OTA updates) in a way compatible with the mincrypt verifier, using SHA1 and RSA keys. + */ +public class ZipSigner { + + static { + if (!KeyStoreFileManager.SECURITY_PROVIDER.getName().equals("SC")) { + throw new RuntimeException("Invalid security provider"); + } + } + + private static final String CERT_SF_NAME = "META-INF/CERT.SF"; + private static final String CERT_RSA_NAME = "META-INF/CERT.RSA"; + + // Files matching this pattern are not copied to the output. + private static final Pattern stripPattern = Pattern.compile("^META-INF/(.*)[.](SF|RSA|DSA)$"); + + /** Add the SHA1 of every file to the manifest, creating it if necessary. */ + private static Manifest addDigestsToManifest(Map entries) + throws IOException, GeneralSecurityException { + Manifest input = null; + ZioEntry manifestEntry = entries.get(JarFile.MANIFEST_NAME); + if (manifestEntry != null) { + input = new Manifest(); + input.read(manifestEntry.getInputStream()); + } + Manifest output = new Manifest(); + Attributes main = output.getMainAttributes(); + if (input != null) { + main.putAll(input.getMainAttributes()); + } else { + main.putValue("Manifest-Version", "1.0"); + main.putValue("Created-By", "1.0 (Android SignApk)"); + } + + MessageDigest md = MessageDigest.getInstance("SHA1"); + byte[] buffer = new byte[512]; + int num; + + // We sort the input entries by name, and add them to the output manifest in sorted order. We expect that the + // output map will be deterministic. + TreeMap byName = new TreeMap<>(); + byName.putAll(entries); + + // if (debug) getLogger().debug("Manifest entries:"); + for (ZioEntry entry : byName.values()) { + String name = entry.getName(); + // if (debug) getLogger().debug(name); + if (!entry.isDirectory() && !name.equals(JarFile.MANIFEST_NAME) && !name.equals(CERT_SF_NAME) + && !name.equals(CERT_RSA_NAME) && (stripPattern == null || !stripPattern.matcher(name).matches())) { + + InputStream data = entry.getInputStream(); + while ((num = data.read(buffer)) > 0) { + md.update(buffer, 0, num); + } + + Attributes attr = null; + if (input != null) { + java.util.jar.Attributes inAttr = input.getAttributes(name); + if (inAttr != null) + attr = new Attributes(inAttr); + } + if (attr == null) + attr = new Attributes(); + attr.putValue("SHA1-Digest", Base64.encode(md.digest())); + output.getEntries().put(name, attr); + } + } + + return output; + } + + /** Write the signature file to the given output stream. */ + private static byte[] generateSignatureFile(Manifest manifest) throws IOException, GeneralSecurityException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(("Signature-Version: 1.0\r\n").getBytes()); + out.write(("Created-By: 1.0 (Android SignApk)\r\n").getBytes()); + + MessageDigest md = MessageDigest.getInstance("SHA1"); + PrintStream print = new PrintStream(new DigestOutputStream(new ByteArrayOutputStream(), md), true, "UTF-8"); + + // Digest of the entire manifest + manifest.write(print); + print.flush(); + + out.write(("SHA1-Digest-Manifest: " + Base64.encode(md.digest()) + "\r\n\r\n").getBytes()); + + Map entries = manifest.getEntries(); + for (Map.Entry entry : entries.entrySet()) { + // Digest of the manifest stanza for this entry. + String nameEntry = "Name: " + entry.getKey() + "\r\n"; + print.print(nameEntry); + for (Map.Entry att : entry.getValue().entrySet()) { + print.print(att.getKey() + ": " + att.getValue() + "\r\n"); + } + print.print("\r\n"); + print.flush(); + + out.write(nameEntry.getBytes()); + out.write(("SHA1-Digest: " + Base64.encode(md.digest()) + "\r\n\r\n").getBytes()); + } + return out.toByteArray(); + } + + /** + * Sign the file using the given public key cert, private key, and signature block template. The signature block + * template parameter may be null, but if so android-sun-jarsign-support.jar must be in the classpath. + */ + public static void signZip(X509Certificate publicKey, PrivateKey privateKey, String signatureAlgorithm, + String inputZipFilename, String outputZipFilename) throws IOException, GeneralSecurityException { + KeySet keySet = new KeySet(publicKey, privateKey, signatureAlgorithm); + + File inFile = new File(inputZipFilename).getCanonicalFile(); + File outFile = new File(outputZipFilename).getCanonicalFile(); + if (inFile.equals(outFile)) + throw new IllegalArgumentException("Input and output files are the same"); + + try (ZipInput input = new ZipInput(inputZipFilename)) { + try (ZipOutput zipOutput = new ZipOutput(new FileOutputStream(outputZipFilename))) { + // Assume the certificate is valid for at least an hour. + long timestamp = publicKey.getNotBefore().getTime() + 3600L * 1000; + + // MANIFEST.MF + Manifest manifest = addDigestsToManifest(input.entries); + ZioEntry ze = new ZioEntry(JarFile.MANIFEST_NAME); + ze.setTime(timestamp); + manifest.write(ze.getOutputStream()); + zipOutput.write(ze); + + byte[] certSfBytes = generateSignatureFile(manifest); + + // CERT.SF + ze = new ZioEntry(CERT_SF_NAME); + ze.setTime(timestamp); + ze.getOutputStream().write(certSfBytes); + zipOutput.write(ze); + + // CERT.RSA + ze = new ZioEntry(CERT_RSA_NAME); + ze.setTime(timestamp); + ze.getOutputStream().write(SignatureBlockGenerator.generate(keySet, certSfBytes)); + zipOutput.write(ze); + + // Copy all the files in a manifest from input to output. We set the modification times in the output to + // a fixed time, so as to reduce variation in the output file and make incremental OTAs more efficient. + Map entries = manifest.getEntries(); + List names = new ArrayList<>(entries.keySet()); + Collections.sort(names); + for (String name : names) { + ZioEntry inEntry = input.entries.get(name); + inEntry.setTime(timestamp); + zipOutput.write(inEntry); + } + } + } + } + +} \ No newline at end of file diff --git a/apksigner/src/main/java/net/fornwall/apksigner/zipio/CentralEnd.java b/apksigner/src/main/java/net/fornwall/apksigner/zipio/CentralEnd.java new file mode 100644 index 0000000..d4723f3 --- /dev/null +++ b/apksigner/src/main/java/net/fornwall/apksigner/zipio/CentralEnd.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2010 Ken Ellinwood + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.fornwall.apksigner.zipio; + +import java.io.IOException; + +class CentralEnd { + + public int signature = 0x06054b50; // end of central dir signature 4 bytes + public short numberThisDisk = 0; // number of this disk 2 bytes + public short centralStartDisk = 0; // number of the disk with the start of + // the central directory 2 bytes + public short numCentralEntries; // total number of entries in the central + // directory on this disk 2 bytes + public short totalCentralEntries; // total number of entries in the central + // directory 2 bytes + + public int centralDirectorySize; // size of the central directory 4 bytes + public int centralStartOffset; // offset of start of central directory with + // respect to the starting disk number 4 + // bytes + public String fileComment; // .ZIP file comment (variable size) + + public static CentralEnd read(ZipInput input) throws IOException { + int signature = input.readInt(); + if (signature != 0x06054b50) { + // Back up to the signature. + input.seek(input.getFilePointer() - 4); + return null; + } + + CentralEnd entry = new CentralEnd(); + entry.numberThisDisk = input.readShort(); + entry.centralStartDisk = input.readShort(); + entry.numCentralEntries = input.readShort(); + entry.totalCentralEntries = input.readShort(); + entry.centralDirectorySize = input.readInt(); + entry.centralStartOffset = input.readInt(); + short zipFileCommentLen = input.readShort(); + entry.fileComment = input.readString(zipFileCommentLen); + return entry; + } + + public void write(ZipOutput output) throws IOException { + output.writeInt(signature); + output.writeShort(numberThisDisk); + output.writeShort(centralStartDisk); + output.writeShort(numCentralEntries); + output.writeShort(totalCentralEntries); + output.writeInt(centralDirectorySize); + output.writeInt(centralStartOffset); + output.writeShort((short) fileComment.length()); + output.writeString(fileComment); + } + +} diff --git a/apksigner/src/main/java/net/fornwall/apksigner/zipio/ZioEntry.java b/apksigner/src/main/java/net/fornwall/apksigner/zipio/ZioEntry.java new file mode 100644 index 0000000..c45c72c --- /dev/null +++ b/apksigner/src/main/java/net/fornwall/apksigner/zipio/ZioEntry.java @@ -0,0 +1,474 @@ +/* + * Copyright (C) 2010 Ken Ellinwood + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.fornwall.apksigner.zipio; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.SequenceInputStream; +import java.util.Date; +import java.util.zip.Inflater; +import java.util.zip.InflaterInputStream; + +public final class ZioEntry implements Cloneable { + + private ZipInput zipInput; + + // public int signature = 0x02014b50; + private short versionMadeBy; + private short versionRequired; + private short generalPurposeBits; + private short compression; + private short modificationTime; + private short modificationDate; + private int crc32; + private int compressedSize; + private int size; + private String filename; + private byte[] extraData; + private short numAlignBytes = 0; + private String fileComment; + private short diskNumberStart; + private short internalAttributes; + private int externalAttributes; + + private int localHeaderOffset; + private long dataPosition = -1; + private byte[] data = null; + private ZioEntryOutputStream entryOut = null; + + private static byte[] alignBytes = new byte[4]; + + public ZioEntry(ZipInput input) throws IOException { + this.zipInput = input; + + // 0 4 Central directory header signature = 0x02014b50 + int signature = input.readInt(); + if (signature != 0x02014b50) { + // back up to the signature + input.seek(input.getFilePointer() - 4); + throw new IOException("Central directory header signature not found"); + } + + // 4 2 Version needed to extract (minimum) + versionMadeBy = input.readShort(); + + // 4 2 Version required + versionRequired = input.readShort(); + + // 6 2 General purpose bit flag + generalPurposeBits = input.readShort(); + // Bits 1, 2, 3, and 11 are allowed to be set (first bit is bit zero). Any others are a problem. + if ((generalPurposeBits & 0xF7F1) != 0x0000) { + throw new IllegalStateException("Can't handle general purpose bits == " + + String.format("0x%04x", generalPurposeBits)); + } + + // 8 2 Compression method + compression = input.readShort(); + + // 10 2 File last modification time + modificationTime = input.readShort(); + + // 12 2 File last modification date + modificationDate = input.readShort(); + + // 14 4 CRC-32 + crc32 = input.readInt(); + + // 18 4 Compressed size + compressedSize = input.readInt(); + + // 22 4 Uncompressed size + size = input.readInt(); + + // 26 2 File name length (n) + short fileNameLen = input.readShort(); + // log.debug(String.format("File name length: 0x%04x", fileNameLen)); + + // 28 2 Extra field length (m) + short extraLen = input.readShort(); + // log.debug(String.format("Extra length: 0x%04x", extraLen)); + + short fileCommentLen = input.readShort(); + diskNumberStart = input.readShort(); + internalAttributes = input.readShort(); + externalAttributes = input.readInt(); + localHeaderOffset = input.readInt(); + + // 30 n File name + filename = input.readString(fileNameLen); + extraData = input.readBytes(extraLen); + fileComment = input.readString(fileCommentLen); + + generalPurposeBits = (short) (generalPurposeBits & 0x0800); // Don't + // write a + // data + // descriptor, + // preserve + // UTF-8 + // encoded + // filename + // bit + + // Don't write zero-length entries with compression. + if (size == 0) { + compressedSize = 0; + compression = 0; + crc32 = 0; + } + } + + public ZioEntry(String name) { + filename = name; + fileComment = ""; + compression = 8; + extraData = new byte[0]; + setTime(System.currentTimeMillis()); + } + + public void readLocalHeader() throws IOException { + ZipInput input = zipInput; + input.seek(localHeaderOffset); + + // 0 4 Local file header signature = 0x04034b50 + int signature = input.readInt(); + if (signature != 0x04034b50) { + throw new IllegalStateException(String.format("Local header not found at pos=0x%08x, file=%s", + input.getFilePointer(), filename)); + } + + // This method is usually called just before the data read, so + // its only purpose currently is to position the file pointer + // for the data read. The entry's attributes might also have + // been changed since the central dir entry was read (e.g., + // filename), so throw away the values here. + + // 4 2 Version needed to extract (minimum) + /* versionRequired */input.readShort(); + + // 6 2 General purpose bit flag + /* generalPurposeBits */input.readShort(); + + // 8 2 Compression method + /* compression */input.readShort(); + + // 10 2 File last modification time + /* modificationTime */input.readShort(); + + // 12 2 File last modification date + /* modificationDate */input.readShort(); + + // 14 4 CRC-32 + /* crc32 */input.readInt(); + + // 18 4 Compressed size + /* compressedSize */input.readInt(); + + // 22 4 Uncompressed size + /* size */input.readInt(); + + // 26 2 File name length (n) + short fileNameLen = input.readShort(); + + // 28 2 Extra field length (m) + short extraLen = input.readShort(); + + // 30 n File name + /* String localFilename = */input.readString(fileNameLen); + + // Extra data. FIXME: Avoid useless memory allocation. + /* byte[] extra = */input.readBytes(extraLen); + + // Record the file position of this entry's data. + dataPosition = input.getFilePointer(); + } + + public void writeLocalEntry(ZipOutput output) throws IOException { + if (data == null && dataPosition < 0 && zipInput != null) + readLocalHeader(); + localHeaderOffset = output.getFilePointer(); + + if (entryOut != null) { + entryOut.close(); + size = entryOut.getSize(); + data = ((ByteArrayOutputStream) entryOut.wrapped).toByteArray(); + compressedSize = data.length; + crc32 = entryOut.getCRC(); + } + + output.writeInt(0x04034b50); + output.writeShort(versionRequired); + output.writeShort(generalPurposeBits); + output.writeShort(compression); + output.writeShort(modificationTime); + output.writeShort(modificationDate); + output.writeInt(crc32); + output.writeInt(compressedSize); + output.writeInt(size); + output.writeShort((short) filename.length()); + + numAlignBytes = 0; + + // Zipalign if the file is uncompressed, i.e., "Stored", and file size is not zero. + if (compression == 0) { + long dataPos = output.getFilePointer() + // current position + 2 + // plus size of extra data length + filename.length() + // plus filename + extraData.length; // plus extra data + short dataPosMod4 = (short) (dataPos % 4); + if (dataPosMod4 > 0) { + numAlignBytes = (short) (4 - dataPosMod4); + } + } + + // 28 2 Extra field length (m) + output.writeShort((short) (extraData.length + numAlignBytes)); + + // 30 n File name + output.writeString(filename); + + // Extra data + output.writeBytes(extraData); + + // Zipalign bytes + if (numAlignBytes > 0) + output.writeBytes(alignBytes, 0, numAlignBytes); + + if (data != null) { + output.writeBytes(data); + } else { + zipInput.seek(dataPosition); + + int bufferSize = Math.min(compressedSize, 8096); + byte[] buffer = new byte[bufferSize]; + long totalCount = 0; + + while (totalCount != compressedSize) { + int numRead = zipInput.in.read(buffer, 0, (int) Math.min(compressedSize - totalCount, bufferSize)); + if (numRead > 0) { + output.writeBytes(buffer, 0, numRead); + // if (debug) + // getLogger().debug( + // String.format("Wrote %d bytes", numRead)); + totalCount += numRead; + } else + throw new IllegalStateException(String.format( + "EOF reached while copying %s with %d bytes left to go", filename, compressedSize + - totalCount)); + } + } + } + + /** Returns the entry's data. */ + public byte[] getData() throws IOException { + if (data != null) + return data; + + byte[] tmpdata = new byte[size]; + + InputStream din = getInputStream(); + int count = 0; + + while (count != size) { + int numRead = din.read(tmpdata, count, size - count); + if (numRead < 0) + throw new IllegalStateException(String.format("Read failed, expecting %d bytes, got %d instead", size, + count)); + count += numRead; + } + return tmpdata; + } + + // Returns an input stream for reading the entry's data. + public InputStream getInputStream() throws IOException { + if (entryOut != null) { + entryOut.close(); + size = entryOut.getSize(); + data = ((ByteArrayOutputStream) entryOut.wrapped).toByteArray(); + compressedSize = data.length; + crc32 = entryOut.getCRC(); + entryOut = null; + InputStream rawis = new ByteArrayInputStream(data); + if (compression == 0) + return rawis; + else { + // Hacky, inflate using a sequence of input streams that returns + // 1 byte more than the actual length of the data. + // This extra dummy byte is required by InflaterInputStream when + // the data doesn't have the header and crc fields (as it is in + // zip files). + return new InflaterInputStream(new SequenceInputStream(rawis, new ByteArrayInputStream(new byte[1])), + new Inflater(true)); + } + } + + ZioEntryInputStream dataStream; + dataStream = new ZioEntryInputStream(this); + if (compression != 0) { + // Note: When using nowrap=true with Inflater it is also necessary to provide + // an extra "dummy" byte as input. This is required by the ZLIB native library + // in order to support certain optimizations. + dataStream.setReturnDummyByte(true); + return new InflaterInputStream(dataStream, new Inflater(true)); + } else + return dataStream; + } + + // Returns an output stream for writing an entry's data. + public OutputStream getOutputStream() { + entryOut = new ZioEntryOutputStream(compression, new ByteArrayOutputStream()); + return entryOut; + } + + public void write(ZipOutput output) throws IOException { + output.writeInt(0x02014b50); + output.writeShort(versionMadeBy); + output.writeShort(versionRequired); + output.writeShort(generalPurposeBits); + output.writeShort(compression); + output.writeShort(modificationTime); + output.writeShort(modificationDate); + output.writeInt(crc32); + output.writeInt(compressedSize); + output.writeInt(size); + output.writeShort((short) filename.length()); + output.writeShort((short) (extraData.length + numAlignBytes)); + output.writeShort((short) fileComment.length()); + output.writeShort(diskNumberStart); + output.writeShort(internalAttributes); + output.writeInt(externalAttributes); + output.writeInt(localHeaderOffset); + + output.writeString(filename); + output.writeBytes(extraData); + if (numAlignBytes > 0) + output.writeBytes(alignBytes, 0, numAlignBytes); + output.writeString(fileComment); + } + + /** Returns time in Java format. */ + public long getTime() { + int year = ((modificationDate >> 9) & 0x007f) + 80; + int month = ((modificationDate >> 5) & 0x000f) - 1; + int day = modificationDate & 0x001f; + int hour = (modificationTime >> 11) & 0x001f; + int minute = (modificationTime >> 5) & 0x003f; + int seconds = (modificationTime << 1) & 0x003e; + Date d = new Date(year, month, day, hour, minute, seconds); + return d.getTime(); + } + + /** Set the file timestamp (using a Java time value). */ + public void setTime(long time) { + Date d = new Date(time); + long dtime; + int year = d.getYear() + 1900; + if (year < 1980) { + dtime = (1 << 21) | (1 << 16); + } else { + dtime = (year - 1980) << 25 | (d.getMonth() + 1) << 21 | d.getDate() << 16 | d.getHours() << 11 + | d.getMinutes() << 5 | d.getSeconds() >> 1; + } + + modificationDate = (short) (dtime >> 16); + modificationTime = (short) (dtime & 0xFFFF); + } + + public boolean isDirectory() { + return filename.endsWith("/"); + } + + public String getName() { + return filename; + } + + public void setName(String filename) { + this.filename = filename; + } + + /** Use 0 (STORED), or 8 (DEFLATE). */ + public void setCompression(int compression) { + this.compression = (short) compression; + } + + public short getVersionMadeBy() { + return versionMadeBy; + } + + public short getVersionRequired() { + return versionRequired; + } + + public short getGeneralPurposeBits() { + return generalPurposeBits; + } + + public short getCompression() { + return compression; + } + + public int getCrc32() { + return crc32; + } + + public int getCompressedSize() { + return compressedSize; + } + + public int getSize() { + return size; + } + + public byte[] getExtraData() { + return extraData; + } + + public String getFileComment() { + return fileComment; + } + + public short getDiskNumberStart() { + return diskNumberStart; + } + + public short getInternalAttributes() { + return internalAttributes; + } + + public int getExternalAttributes() { + return externalAttributes; + } + + public int getLocalHeaderOffset() { + return localHeaderOffset; + } + + public long getDataPosition() { + return dataPosition; + } + + public ZioEntryOutputStream getEntryOut() { + return entryOut; + } + + public ZipInput getZipInput() { + return zipInput; + } + +} diff --git a/apksigner/src/main/java/net/fornwall/apksigner/zipio/ZioEntryInputStream.java b/apksigner/src/main/java/net/fornwall/apksigner/zipio/ZioEntryInputStream.java new file mode 100644 index 0000000..c4f2c4b --- /dev/null +++ b/apksigner/src/main/java/net/fornwall/apksigner/zipio/ZioEntryInputStream.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2010 Ken Ellinwood + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.fornwall.apksigner.zipio; + +import java.io.IOException; +import java.io.InputStream; +import java.io.RandomAccessFile; + +/** Input stream used to read just the data from a zip file entry. */ +final class ZioEntryInputStream extends InputStream { + + RandomAccessFile raf; + int size; + int offset; + boolean returnDummyByte = false; + + public ZioEntryInputStream(ZioEntry entry) throws IOException { + offset = 0; + size = entry.getCompressedSize(); + raf = entry.getZipInput().in; + long dpos = entry.getDataPosition(); + if (dpos >= 0) { + raf.seek(entry.getDataPosition()); + } else { + // seeks to, then reads, the local header, causing the + // file pointer to be positioned at the start of the data. + entry.readLocalHeader(); + } + + } + + public void setReturnDummyByte(boolean returnExtraByte) { + returnDummyByte = returnExtraByte; + } + + @Override + public void close() throws IOException { + } + + @Override + public boolean markSupported() { + return false; + } + + @Override + public int available() throws IOException { + int available = size - offset; + // log.debug(String.format("Available = %d", available)); + if (available == 0 && returnDummyByte) + return 1; + else + return available; + } + + @Override + public int read() throws IOException { + if ((size - offset) == 0) { + if (returnDummyByte) { + returnDummyByte = false; + return 0; + } else + return -1; + } + int b = raf.read(); + if (b >= 0) { + offset += 1; + } + return b; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + return readBytes(b, off, len); + } + + private int readBytes(byte[] b, int off, int len) throws IOException { + if ((size - offset) == 0) { + if (returnDummyByte) { + returnDummyByte = false; + b[off] = 0; + return 1; + } else + return -1; + } + int numToRead = Math.min(len, available()); + int numRead = raf.read(b, off, numToRead); + if (numRead > 0) { + offset += numRead; + } + return numRead; + } + + @Override + public int read(byte[] b) throws IOException { + return readBytes(b, 0, b.length); + } + + @Override + public long skip(long n) throws IOException { + long numToSkip = Math.min(n, available()); + raf.seek(raf.getFilePointer() + numToSkip); + return numToSkip; + } +} diff --git a/apksigner/src/main/java/net/fornwall/apksigner/zipio/ZioEntryOutputStream.java b/apksigner/src/main/java/net/fornwall/apksigner/zipio/ZioEntryOutputStream.java new file mode 100644 index 0000000..9024bbf --- /dev/null +++ b/apksigner/src/main/java/net/fornwall/apksigner/zipio/ZioEntryOutputStream.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2010 Ken Ellinwood + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.fornwall.apksigner.zipio; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.zip.CRC32; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; + +final class ZioEntryOutputStream extends OutputStream { + + int size = 0; // tracks uncompressed size of data + final CRC32 crc = new CRC32(); + int crcValue = 0; + final OutputStream wrapped; + final OutputStream downstream; + + public ZioEntryOutputStream(int compression, OutputStream wrapped) { + this.wrapped = wrapped; + downstream = (compression == 0) ? wrapped : new DeflaterOutputStream(wrapped, new Deflater( + Deflater.BEST_COMPRESSION, true)); + } + + @Override + public void close() throws IOException { + downstream.close(); + crcValue = (int) crc.getValue(); + } + + public int getCRC() { + return crcValue; + } + + @Override + public void flush() throws IOException { + downstream.flush(); + } + + @Override + public void write(byte[] b) throws IOException { + downstream.write(b); + crc.update(b); + size += b.length; + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + downstream.write(b, off, len); + crc.update(b, off, len); + size += len; + } + + @Override + public void write(int b) throws IOException { + downstream.write(b); + crc.update(b); + size += 1; + } + + public int getSize() { + return size; + } + +} diff --git a/apksigner/src/main/java/net/fornwall/apksigner/zipio/ZipInput.java b/apksigner/src/main/java/net/fornwall/apksigner/zipio/ZipInput.java new file mode 100644 index 0000000..28429d8 --- /dev/null +++ b/apksigner/src/main/java/net/fornwall/apksigner/zipio/ZipInput.java @@ -0,0 +1,113 @@ +package net.fornwall.apksigner.zipio; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.jar.Manifest; + +public final class ZipInput implements AutoCloseable { + + final RandomAccessFile in; + final long fileLength; + int scanIterations = 0; + + public final Map entries = new LinkedHashMap<>(); + final CentralEnd centralEnd; + Manifest manifest; + + public ZipInput(String filename) throws IOException { + in = new RandomAccessFile(filename, "r"); + fileLength = in.length(); + + long posEOCDR = scanForEOCDR((int) Math.min(fileLength, 256)); + in.seek(posEOCDR); + centralEnd = CentralEnd.read(this); + in.seek(centralEnd.centralStartOffset); + + for (int i = 0; i < centralEnd.totalCentralEntries; i++) { + ZioEntry entry = new ZioEntry(this); + entries.put(entry.getName(), entry); + } + } + + public Manifest getManifest() throws IOException { + if (manifest == null) { + ZioEntry e = entries.get("META-INF/MANIFEST.MF"); + if (e != null) + manifest = new Manifest(e.getInputStream()); + } + return manifest; + } + + /** + * Scan the end of the file for the end of central directory record (EOCDR). Returns the file offset of the EOCD + * signature. The size parameter is an initial buffer size (e.g., 256). + */ + public long scanForEOCDR(int size) throws IOException { + if (size > fileLength || size > 65536) + throw new IllegalStateException("End of central directory not found"); + + int scanSize = (int) Math.min(fileLength, size); + + byte[] scanBuf = new byte[scanSize]; + in.seek(fileLength - scanSize); + in.readFully(scanBuf); + + for (int i = scanSize - 22; i >= 0; i--) { + scanIterations += 1; + if (scanBuf[i] == 0x50 && scanBuf[i + 1] == 0x4b && scanBuf[i + 2] == 0x05 && scanBuf[i + 3] == 0x06) { + return fileLength - scanSize + i; + } + } + + return scanForEOCDR(size * 2); + } + + @Override + public void close() { + if (in != null) + try { + in.close(); + } catch (Throwable t) { + } + } + + public long getFilePointer() throws IOException { + return in.getFilePointer(); + } + + public void seek(long position) throws IOException { + in.seek(position); + } + + public int readInt() throws IOException { + int result = 0; + for (int i = 0; i < 4; i++) + result |= (in.readUnsignedByte() << (8 * i)); + return result; + } + + public short readShort() throws IOException { + short result = 0; + for (int i = 0; i < 2; i++) + result |= (in.readUnsignedByte() << (8 * i)); + return result; + } + + public String readString(int length) throws IOException { + byte[] buffer = new byte[length]; + for (int i = 0; i < length; i++) + buffer[i] = in.readByte(); + return new String(buffer); + } + + public byte[] readBytes(int length) throws IOException { + byte[] buffer = new byte[length]; + for (int i = 0; i < length; i++) { + buffer[i] = in.readByte(); + } + return buffer; + } + +} diff --git a/apksigner/src/main/java/net/fornwall/apksigner/zipio/ZipOutput.java b/apksigner/src/main/java/net/fornwall/apksigner/zipio/ZipOutput.java new file mode 100644 index 0000000..a643cb1 --- /dev/null +++ b/apksigner/src/main/java/net/fornwall/apksigner/zipio/ZipOutput.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2010 Ken Ellinwood + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.fornwall.apksigner.zipio; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +public final class ZipOutput implements AutoCloseable { + + final OutputStream out; + int filePointer = 0; + final List entriesWritten = new LinkedList<>(); + final Set namesWritten = new HashSet<>(); + + public ZipOutput(OutputStream out) { + this.out = out; + } + + public void write(ZioEntry entry) throws IOException { + String entryName = entry.getName(); + if (namesWritten.contains(entryName)) { + System.err.println("Skipping duplicate file in output: " + entryName); + return; + } + entry.writeLocalEntry(this); + entriesWritten.add(entry); + namesWritten.add(entryName); + } + + public int getFilePointer() { + return filePointer; + } + + public void writeInt(int value) throws IOException { + byte[] data = new byte[4]; + for (int i = 0; i < 4; i++) { + data[i] = (byte) (value & 0xFF); + value = value >> 8; + } + out.write(data); + filePointer += 4; + } + + public void writeShort(short value) throws IOException { + byte[] data = new byte[2]; + for (int i = 0; i < 2; i++) { + data[i] = (byte) (value & 0xFF); + value = (short) (value >> 8); + } + out.write(data); + filePointer += 2; + } + + public void writeString(String value) throws IOException { + byte[] data = value.getBytes(); + out.write(data); + filePointer += data.length; + } + + public void writeBytes(byte[] value) throws IOException { + out.write(value); + filePointer += value.length; + } + + public void writeBytes(byte[] value, int offset, int length) throws IOException { + out.write(value, offset, length); + filePointer += length; + } + + @Override + public void close() throws IOException { + CentralEnd centralEnd = new CentralEnd(); + centralEnd.centralStartOffset = getFilePointer(); + centralEnd.numCentralEntries = centralEnd.totalCentralEntries = (short) entriesWritten.size(); + + for (ZioEntry entry : entriesWritten) + entry.write(this); + + centralEnd.centralDirectorySize = (getFilePointer() - centralEnd.centralStartOffset); + centralEnd.fileComment = ""; + + centralEnd.write(this); + + if (out != null) + try { + out.close(); + } catch (Throwable t) { + } + } + +} diff --git a/build.gradle b/build.gradle index 4e4686f..17311c5 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,7 @@ allprojects { apply plugin: 'maven' apply plugin: 'idea' apply plugin: 'eclipse' - version = '2.0' + version = '2.0.1' } defaultTasks('clean','distZip') @@ -11,8 +11,8 @@ defaultTasks('clean','distZip') subprojects { apply plugin: 'java' apply plugin: 'maven' - sourceCompatibility = 1.7 - targetCompatibility = 1.7 + sourceCompatibility = 1.8 + targetCompatibility = 1.8 diff --git a/settings.gradle b/settings.gradle index 9f545c6..719fd5d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,2 @@ rootProject.name = 'Xpatch' -include ':axmlprinter', ':xpatch' +include ':axmlprinter', ':xpatch', ':apksigner' diff --git a/xpatch/build.gradle b/xpatch/build.gradle index b94a106..2765bff 100644 --- a/xpatch/build.gradle +++ b/xpatch/build.gradle @@ -3,6 +3,7 @@ apply plugin: 'java-library' dependencies { compile fileTree(dir: 'libs', include: ['*.jar']) compile project(':axmlprinter') + compile project(':apksigner') } jar{ diff --git a/xpatch/libs/ManifestEditor-1.0.0.jar b/xpatch/libs/ManifestEditor-1.0.0.jar new file mode 100644 index 0000000..cbb4c1a Binary files /dev/null and b/xpatch/libs/ManifestEditor-1.0.0.jar differ diff --git a/xpatch/src/main/assets/classes-1.0.dex b/xpatch/src/main/assets/classes-1.0.dex new file mode 100644 index 0000000..1710f6b Binary files /dev/null and b/xpatch/src/main/assets/classes-1.0.dex differ diff --git a/xpatch/src/main/assets/classes.dex b/xpatch/src/main/assets/classes.dex deleted file mode 100755 index c35503d..0000000 Binary files a/xpatch/src/main/assets/classes.dex and /dev/null differ diff --git a/xpatch/src/main/assets/lib/arm64-v8a/libsandhook b/xpatch/src/main/assets/lib/arm64-v8a/libsandhook new file mode 100644 index 0000000..d218a0a Binary files /dev/null and b/xpatch/src/main/assets/lib/arm64-v8a/libsandhook differ diff --git a/xpatch/src/main/assets/lib/arm64-v8a/libsandhook.so b/xpatch/src/main/assets/lib/arm64-v8a/libsandhook.so deleted file mode 100755 index a314f93..0000000 Binary files a/xpatch/src/main/assets/lib/arm64-v8a/libsandhook.so and /dev/null differ diff --git a/xpatch/src/main/assets/lib/arm64-v8a/libwhale b/xpatch/src/main/assets/lib/arm64-v8a/libwhale new file mode 100644 index 0000000..4a2a263 Binary files /dev/null and b/xpatch/src/main/assets/lib/arm64-v8a/libwhale differ diff --git a/xpatch/src/main/assets/lib/armeabi-v7a/libsandhook b/xpatch/src/main/assets/lib/armeabi-v7a/libsandhook new file mode 100644 index 0000000..5e4bbf8 Binary files /dev/null and b/xpatch/src/main/assets/lib/armeabi-v7a/libsandhook differ diff --git a/xpatch/src/main/assets/lib/armeabi-v7a/libsandhook.so b/xpatch/src/main/assets/lib/armeabi-v7a/libsandhook.so deleted file mode 100755 index fdf95fa..0000000 Binary files a/xpatch/src/main/assets/lib/armeabi-v7a/libsandhook.so and /dev/null differ diff --git a/xpatch/src/main/assets/lib/armeabi-v7a/libwhale b/xpatch/src/main/assets/lib/armeabi-v7a/libwhale new file mode 100644 index 0000000..8f93a05 Binary files /dev/null and b/xpatch/src/main/assets/lib/armeabi-v7a/libwhale differ diff --git a/xpatch/src/main/java/com/storm/wind/xpatch/MainCommand.java b/xpatch/src/main/java/com/storm/wind/xpatch/MainCommand.java index 5e78485..3ade4f6 100644 --- a/xpatch/src/main/java/com/storm/wind/xpatch/MainCommand.java +++ b/xpatch/src/main/java/com/storm/wind/xpatch/MainCommand.java @@ -4,9 +4,14 @@ import com.storm.wind.xpatch.base.BaseCommand; import com.storm.wind.xpatch.task.ApkModifyTask; import com.storm.wind.xpatch.task.BuildAndSignApkTask; import com.storm.wind.xpatch.task.SaveApkSignatureTask; +import com.storm.wind.xpatch.task.SaveOriginalApplicationNameTask; import com.storm.wind.xpatch.task.SoAndDexCopyTask; import com.storm.wind.xpatch.util.FileUtils; import com.storm.wind.xpatch.util.ManifestParser; +import com.wind.meditor.core.FileProcesser; +import com.wind.meditor.property.AttributeItem; +import com.wind.meditor.property.ModificationProperty; +import com.wind.meditor.utils.NodeValue; import java.io.File; import java.text.SimpleDateFormat; @@ -39,16 +44,38 @@ public class MainCommand extends BaseCommand { description = "disable craching the apk's signature.") private boolean disableCrackSignature = false; - @Opt(opt = "xm", longOpt = "xposed-modules", description = "the xposed mpdule files to be packaged into the apk, " + - "multi files should be seperated by :(mac) or ;(win) ") + @Opt(opt = "xm", longOpt = "xposed-modules", description = "the xposed module files to be packaged into the apk, " + + "multi files should be seperated by :(mac) or ;(win) ", argName = "xposed module file path") private String xposedModules; + // 使用dex文件中插入代码的方式修改apk,而不是默认的修改Manifest中Application name的方式 + @Opt(opt = "dex", longOpt = "dex", hasArg = false, description = "insert code into the dex file, not modify manifest application name attribute") + private boolean dexModificationMode = false; + + @Opt(opt = "pkg", longOpt = "packageName", description = "modify the apk package name", argName = "new package name") + private String newPackageName; + + @Opt(opt = "d", longOpt = "debuggable", description = "set 1 to make the app debuggable = true, " + + "set 0 to make the app debuggable = false", argName = "0 or 1") + private int debuggable = -1; // 0: debuggable = false 1: debuggable = true + + @Opt(opt = "vc", longOpt = "versionCode", description = "set the app version code", + argName = "new-version-code") + private int versionCode; + + @Opt(opt = "vn", longOpt = "versionName", description = "set the app version name", + argName = "new-version-name") + private String versionName; + + @Opt(opt = "w", longOpt = "whale", hasArg = false, description = "Change hook framework to Lody's whale") + private boolean useWhaleHookFramework = false; // 是否使用whale hook框架,默认使用的是SandHook + // 原来apk中dex文件的数量 private int dexFileCount = 0; private static final String UNZIP_APK_FILE_NAME = "apk-unzip-files"; - private static final String DEFAULT_APPLICATION_NAME = "android.app.Application"; + private static final String PROXY_APPLICATION_NAME = "com.wind.xpatch.proxy.XpatchProxyApplication"; private List mXpatchTasks = new ArrayList<>(); @@ -79,13 +106,6 @@ public class MainCommand extends BaseCommand { return; } - String srcApkFileParentPath = srcApkFile.getParent(); - if (srcApkFileParentPath == null) { - String absPath = srcApkFile.getAbsolutePath(); - int index = absPath.lastIndexOf(File.separatorChar); - srcApkFileParentPath = absPath.substring(0, index); - } - String currentDir = new File(".").getAbsolutePath(); // 当前命令行所在的目录 if (showAllLogs) { System.out.println(" currentDir = " + currentDir + " \n apkPath = " + apkPath); @@ -102,20 +122,27 @@ public class MainCommand extends BaseCommand { return; } + String outputApkFileParentPath = outputFile.getParent(); + if (outputApkFileParentPath == null) { + String absPath = outputFile.getAbsolutePath(); + int index = absPath.lastIndexOf(File.separatorChar); + outputApkFileParentPath = absPath.substring(0, index); + } + System.out.println(" !!!!! output apk path --> " + output + " disableCrackSignature --> " + disableCrackSignature); String apkFileName = getBaseName(srcApkFile); // 中间文件临时存储的位置 - String tempFilePath = srcApkFileParentPath + File.separator + + String tempFilePath = outputApkFileParentPath + File.separator + currentTimeStr() + "-tmp" + File.separator; // apk文件解压的目录 unzipApkFilePath = tempFilePath + apkFileName + "-" + UNZIP_APK_FILE_NAME + File.separator; if (showAllLogs) { - System.out.println(" !!!!! srcApkFileParentPath = " + srcApkFileParentPath + + System.out.println(" !!!!! outputApkFileParentPath = " + outputApkFileParentPath + "\n unzipApkFilePath = " + unzipApkFilePath); } @@ -125,8 +152,13 @@ public class MainCommand extends BaseCommand { } // 先解压apk到指定目录下 + long currentTime = System.currentTimeMillis(); FileUtils.decompressZip(apkPath, unzipApkFilePath); + if (showAllLogs) { + System.out.println(" decompress apk cost time: " + (System.currentTimeMillis() - currentTime)); + } + // Get the dex count in the apk zip file dexFileCount = findDexFileCount(unzipApkFilePath); @@ -136,33 +168,56 @@ public class MainCommand extends BaseCommand { String manifestFilePath = unzipApkFilePath + "AndroidManifest.xml"; + currentTime = System.currentTimeMillis(); + // parse the app main application full name from the manifest file ManifestParser.Pair pair = ManifestParser.parseManifestFile(manifestFilePath); - String applicationName; + String applicationName = null; if (pair != null && pair.applicationName != null) { applicationName = pair.applicationName; - } else { - System.out.println(" Application name not found error !!!!!! "); - applicationName = DEFAULT_APPLICATION_NAME; } if (showAllLogs) { + System.out.println(" Get application name cost time: " + (System.currentTimeMillis() - currentTime)); System.out.println(" Get the application name --> " + applicationName); } - // 1. modify the apk dex file to make xposed can run in it - mXpatchTasks.add(new ApkModifyTask(showAllLogs, keepBuildFiles, unzipApkFilePath, applicationName, - dexFileCount)); + // modify manifest + File manifestFile = new File(manifestFilePath); + String manifestFilePathNew = unzipApkFilePath + "AndroidManifest" + "-" + currentTimeStr() + ".xml"; + File manifestFileNew = new File(manifestFilePathNew); + manifestFile.renameTo(manifestFileNew); - // 2. copy xposed so and dex files into the unzipped apk - mXpatchTasks.add(new SoAndDexCopyTask(dexFileCount, unzipApkFilePath, getXposedModules(xposedModules))); + modifyManifestFile(manifestFilePathNew, manifestFilePath, applicationName); + manifestFileNew.delete(); - // 3. compress all files into an apk and then sign it. + // save original main application name to asset file + if (isNotEmpty(applicationName)) { + mXpatchTasks.add(new SaveOriginalApplicationNameTask(applicationName, unzipApkFilePath)); + } + + // modify the apk dex file to make xposed can run in it + if (dexModificationMode && isNotEmpty(applicationName)) { + mXpatchTasks.add(new ApkModifyTask(showAllLogs, keepBuildFiles, unzipApkFilePath, applicationName, + dexFileCount)); + } + + // copy xposed so and dex files into the unzipped apk + mXpatchTasks.add(new SoAndDexCopyTask(dexFileCount, unzipApkFilePath, + getXposedModules(xposedModules), useWhaleHookFramework)); + + // compress all files into an apk and then sign it. mXpatchTasks.add(new BuildAndSignApkTask(keepBuildFiles, unzipApkFilePath, output)); - // 4. excute these tasks + // excute these tasks for (Runnable executor : mXpatchTasks) { + currentTime = System.currentTimeMillis(); executor.run(); + + if (showAllLogs) { + System.out.println(executor.getClass().getSimpleName() + " cost time: " + + (System.currentTimeMillis() - currentTime)); + } } // 5. delete all the build files that is useless now @@ -177,6 +232,39 @@ public class MainCommand extends BaseCommand { } } + private void modifyManifestFile(String filePath, String dstFilePath, String originalApplicationName) { + ModificationProperty property = new ModificationProperty(); + boolean modifyEnabled = false; + if (isNotEmpty(newPackageName)) { + modifyEnabled = true; + property.addManifestAttribute(new AttributeItem(NodeValue.Manifest.PACKAGE, newPackageName).setNamespace(null)); + } + + if (versionCode > 0) { + modifyEnabled = true; + property.addManifestAttribute(new AttributeItem(NodeValue.Manifest.VERSION_CODE, versionCode)); + } + + if (isNotEmpty(versionName)) { + modifyEnabled = true; + property.addManifestAttribute(new AttributeItem(NodeValue.Manifest.VERSION_NAME, versionName)); + } + + if (debuggable >= 0) { + modifyEnabled = true; + property.addApplicationAttribute(new AttributeItem(NodeValue.Application.DEBUGGABLE, debuggable != 0)); + } + + if (!dexModificationMode || !isNotEmpty(originalApplicationName)) { + modifyEnabled = true; + property.addApplicationAttribute(new AttributeItem(NodeValue.Application.NAME, PROXY_APPLICATION_NAME)); + } + + if (modifyEnabled) { + FileProcesser.processManifestFile(filePath, dstFilePath, property); + } + } + private int findDexFileCount(String unzipApkFilePath) { File zipfileRoot = new File(unzipApkFilePath); if (!zipfileRoot.exists()) { @@ -208,4 +296,8 @@ public class MainCommand extends BaseCommand { } return modules.split(File.pathSeparator); } + + private static boolean isNotEmpty(String str) { + return str != null && !str.isEmpty(); + } } diff --git a/xpatch/src/main/java/com/storm/wind/xpatch/base/BaseCommand.java b/xpatch/src/main/java/com/storm/wind/xpatch/base/BaseCommand.java index f804536..6407878 100644 --- a/xpatch/src/main/java/com/storm/wind/xpatch/base/BaseCommand.java +++ b/xpatch/src/main/java/com/storm/wind/xpatch/base/BaseCommand.java @@ -8,6 +8,8 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.ArrayList; @@ -251,7 +253,23 @@ public abstract class BaseCommand { Option needArgOpt = null; for (String s : args) { if (needArgOpt != null) { - needArgOpt.field.set(this, convert(s, needArgOpt.field.getType())); + Field field = needArgOpt.field; + Class clazz = field.getType(); + if (clazz.equals(List.class)) { + try { + List object = ((List) field.get(this)); + + // 获取List对象的泛型类型 + ParameterizedType listGenericType = (ParameterizedType) field.getGenericType(); + Type[] listActualTypeArguments = listGenericType.getActualTypeArguments(); + Class typeClazz = (Class) listActualTypeArguments[0]; + object.add(convert(s, typeClazz)); + } catch (Exception e) { + e.printStackTrace(); + } + } else { + field.set(this, convert(s, clazz)); + } needArgOpt = null; } else if (s.startsWith("-")) {// its a short or long option Option opt = optMap.get(s); diff --git a/xpatch/src/main/java/com/storm/wind/xpatch/task/ApkModifyTask.java b/xpatch/src/main/java/com/storm/wind/xpatch/task/ApkModifyTask.java index 89941eb..d13a98f 100644 --- a/xpatch/src/main/java/com/storm/wind/xpatch/task/ApkModifyTask.java +++ b/xpatch/src/main/java/com/storm/wind/xpatch/task/ApkModifyTask.java @@ -36,7 +36,7 @@ public class ApkModifyTask implements Runnable { String jarOutputPath = unzipApkFile.getParent() + File.separator + JAR_FILE_NAME; - // classes.dex + // classes-1.0.dex String targetDexFileName = dumpJarFile(dexFileCount, unzipApkFilePath, jarOutputPath, applicationName); if (showAllLogs) { @@ -104,12 +104,12 @@ public class ApkModifyTask implements Runnable { cmd.doMain(args); } - // 列出目录下所有dex文件,classes.dex,classes2.dex,classes3.dex ..... + // 列出目录下所有dex文件,classes-1.0.dex,classes2.dex,classes3.dex ..... private ArrayList createClassesDotDexFileList(int dexFileCount) { ArrayList list = new ArrayList<>(); for (int i = 0; i < dexFileCount; i++) { if (i == 0) { - list.add("classes.dex"); + list.add("classes-1.0.dex"); } else { list.add("classes" + (i + 1) + ".dex"); } diff --git a/xpatch/src/main/java/com/storm/wind/xpatch/task/BuildAndSignApkTask.java b/xpatch/src/main/java/com/storm/wind/xpatch/task/BuildAndSignApkTask.java index da72f2b..ab76f72 100644 --- a/xpatch/src/main/java/com/storm/wind/xpatch/task/BuildAndSignApkTask.java +++ b/xpatch/src/main/java/com/storm/wind/xpatch/task/BuildAndSignApkTask.java @@ -55,6 +55,22 @@ public class BuildAndSignApkTask implements Runnable { } private boolean signApk(String apkPath, String keyStorePath, String signedApkPath, boolean useLocalJarsigner) { + if (isAndroid()) { + boolean success = true; + try { + ShellCmdUtil.chmod((new File(apkPath)).getParent(), ShellCmdUtil.FileMode.MODE_755); + net.fornwall.apksigner.Main.main + ("--password", "123456", keyStorePath, apkPath, signedApkPath); + } catch (Exception e1) { + success = false; + e1.printStackTrace(); + System.out.println("use fornwall apksigner to sign apk failed, fail msg is :" + e1.toString()); + } + if (success && new File(signedApkPath).exists()) { + return true; + } + } + File localJarsignerFile = null; try { long time = System.currentTimeMillis(); @@ -67,7 +83,8 @@ public class BuildAndSignApkTask implements Runnable { String localJarsignerPath = (new File(apkPath)).getParent() + File.separator + "jarsigner-081688"; localJarsignerFile = new File(localJarsignerPath); FileUtils.copyFileFromJar("assets/jarsigner", localJarsignerPath); - ShellCmdUtil.execCmd("chmod -R 777 " + localJarsignerPath, null); + ShellCmdUtil.chmod(localJarsignerPath, ShellCmdUtil.FileMode.MODE_755); + // ShellCmdUtil.execCmd("chmod -R 777 " + localJarsignerPath, null); signCmd = new StringBuilder(localJarsignerPath + " "); } signCmd.append(" -keystore ") @@ -96,6 +113,15 @@ public class BuildAndSignApkTask implements Runnable { } else { System.out.println("use inner jarsigner to sign apk failed, sign it yourself fail msg is :" + e.toString()); + + try { + net.fornwall.apksigner.Main.main + ("--password", "123456", keyStorePath, apkPath, signedApkPath); + } catch (Exception e1) { + e1.printStackTrace(); + System.out.println("use fornwall apksigner to sign apk failed, fail msg is :" + + e1.toString()); + } } return false; } finally { @@ -104,4 +130,14 @@ public class BuildAndSignApkTask implements Runnable { } } } + + private boolean isAndroid() { + boolean isAndroid = true; + try { + Class.forName("android.content.Context"); + } catch (ClassNotFoundException e) { + isAndroid = false; + } + return isAndroid; + } } diff --git a/xpatch/src/main/java/com/storm/wind/xpatch/task/SaveOriginalApplicationNameTask.java b/xpatch/src/main/java/com/storm/wind/xpatch/task/SaveOriginalApplicationNameTask.java new file mode 100644 index 0000000..0b73e77 --- /dev/null +++ b/xpatch/src/main/java/com/storm/wind/xpatch/task/SaveOriginalApplicationNameTask.java @@ -0,0 +1,40 @@ +package com.storm.wind.xpatch.task; + +import com.storm.wind.xpatch.util.FileUtils; + +import java.io.File; + +/** + * Created by xiawanli on 2019/4/6 + */ +public class SaveOriginalApplicationNameTask implements Runnable { + + private final String applcationName; + private final String unzipApkFilePath; + private String dstFilePath; + + private final String APPLICATION_NAME_ASSET_PATH = "assets/xpatch_asset/original_application_name.ini"; + + public SaveOriginalApplicationNameTask(String applicationName, String unzipApkFilePath) { + this.applcationName = applicationName; + this.unzipApkFilePath = unzipApkFilePath; + + this.dstFilePath = (unzipApkFilePath + APPLICATION_NAME_ASSET_PATH).replace("/", File.separator); + } + + @Override + public void run() { + ensureDstFileCreated(); + FileUtils.writeFile(dstFilePath, applcationName); + } + + private void ensureDstFileCreated() { + File dstParentFile = new File(dstFilePath); + if (!dstParentFile.getParentFile().getParentFile().exists()) { + dstParentFile.getParentFile().getParentFile().mkdirs(); + } + if (!dstParentFile.getParentFile().exists()) { + dstParentFile.getParentFile().mkdirs(); + } + } +} \ No newline at end of file diff --git a/xpatch/src/main/java/com/storm/wind/xpatch/task/SoAndDexCopyTask.java b/xpatch/src/main/java/com/storm/wind/xpatch/task/SoAndDexCopyTask.java index 6b3f031..18456fa 100644 --- a/xpatch/src/main/java/com/storm/wind/xpatch/task/SoAndDexCopyTask.java +++ b/xpatch/src/main/java/com/storm/wind/xpatch/task/SoAndDexCopyTask.java @@ -10,7 +10,10 @@ import java.util.HashMap; */ public class SoAndDexCopyTask implements Runnable { - private static final String SO_FILE_NAME = "libsandhook.so"; + private static final String SANDHOOK_SO_FILE_NAME = "libsandhook"; + private static final String WHALE_SO_FILE_NAME = "libwhale"; + + private static final String SO_FILE_NAME_WITH_SUFFIX = "libsandhook"; private static final String XPOSED_MODULE_FILE_NAME_PREFIX = "libxpatch_xp_module_"; private static final String SO_FILE_SUFFIX = ".so"; @@ -20,22 +23,27 @@ public class SoAndDexCopyTask implements Runnable { "lib/arm64-v8a/" }; - private final HashMap SO_FILE_PATH_MAP = new HashMap() { - { - put(APK_LIB_PATH_ARRAY[0], "assets/lib/armeabi-v7a/" + SO_FILE_NAME); - put(APK_LIB_PATH_ARRAY[1], "assets/lib/armeabi-v7a/" + SO_FILE_NAME); - put(APK_LIB_PATH_ARRAY[2], "assets/lib/arm64-v8a/" + SO_FILE_NAME); - } - }; - + private final HashMap mSoFilePathMap = new HashMap<>(); private int dexFileCount; private String unzipApkFilePath; private String[] xposedModuleArray; - public SoAndDexCopyTask(int dexFileCount, String unzipApkFilePath, String[] xposedModuleArray) { + public SoAndDexCopyTask(int dexFileCount, String unzipApkFilePath, + String[] xposedModuleArray, boolean useWhaleHookFramework) { this.dexFileCount = dexFileCount; this.unzipApkFilePath = unzipApkFilePath; this.xposedModuleArray = xposedModuleArray; + + String soFileName; + if (useWhaleHookFramework) { + soFileName = WHALE_SO_FILE_NAME; + } else { + soFileName = SANDHOOK_SO_FILE_NAME; + } + + mSoFilePathMap.put(APK_LIB_PATH_ARRAY[0], "assets/lib/armeabi-v7a/" + soFileName); + mSoFilePathMap.put(APK_LIB_PATH_ARRAY[1], "assets/lib/armeabi-v7a/" + soFileName); + mSoFilePathMap.put(APK_LIB_PATH_ARRAY[2], "assets/lib/arm64-v8a/" + soFileName); } @Override @@ -49,14 +57,33 @@ public class SoAndDexCopyTask implements Runnable { } private void copySoFile() { + String[] existLibPathArray = new String[3]; + int arrayIndex = 0; for (String libPath : APK_LIB_PATH_ARRAY) { String apkSoFullPath = fullLibPath(libPath); - File apkSoFullPathFile= new File(apkSoFullPath); - if (!apkSoFullPathFile.exists()){ - apkSoFullPathFile.mkdirs(); + File apkSoFullPathFile = new File(apkSoFullPath); + if (apkSoFullPathFile.exists()) { + existLibPathArray[arrayIndex] = libPath; + arrayIndex++; } - copyLibFile(apkSoFullPath, SO_FILE_PATH_MAP.get(libPath)); } + + // 不存在lib目录,则创建lib/armeabi-v7 文件夹 + if (arrayIndex == 0) { + String libPath = APK_LIB_PATH_ARRAY[0]; + String apkSoFullPath = fullLibPath(libPath); + File apkSoFullPathFile = new File(apkSoFullPath); + apkSoFullPathFile.mkdirs(); + existLibPathArray[arrayIndex] = libPath; + } + + for (String libPath : existLibPathArray) { + if (libPath != null && !libPath.isEmpty()) { + String apkSoFullPath = fullLibPath(libPath); + copyLibFile(apkSoFullPath, mSoFilePathMap.get(libPath)); + } + } + // copy xposed modules into the lib path if (xposedModuleArray != null && xposedModuleArray.length > 0) { int index = 0; @@ -69,14 +96,13 @@ public class SoAndDexCopyTask implements Runnable { if (!moduleFile.exists()) { continue; } - for (String libPath : APK_LIB_PATH_ARRAY) { - String apkSoFullPath = fullLibPath(libPath); - String outputModuleName= XPOSED_MODULE_FILE_NAME_PREFIX + index + SO_FILE_SUFFIX; - if(new File(apkSoFullPath).exists()) { + for (String libPath : existLibPathArray) { + if (libPath != null && !libPath.isEmpty()) { + String apkSoFullPath = fullLibPath(libPath); + String outputModuleName = XPOSED_MODULE_FILE_NAME_PREFIX + index + SO_FILE_SUFFIX; File outputModuleSoFile = new File(apkSoFullPath, outputModuleName); FileUtils.copyFile(moduleFile, outputModuleSoFile); } - } index++; } @@ -87,7 +113,7 @@ public class SoAndDexCopyTask implements Runnable { // copy dex file to root dir, rename it first String copiedDexFileName = "classes" + (dexFileCount + 1) + ".dex"; // assets/classes.dex分隔符不能使用File.seperater,否则在windows上无法读取到文件,IOException - FileUtils.copyFileFromJar("assets/classes.dex", unzipApkFilePath + copiedDexFileName); + FileUtils.copyFileFromJar("assets/classes-1.0.dex", unzipApkFilePath + copiedDexFileName); } private String fullLibPath(String libPath) { @@ -101,16 +127,15 @@ public class SoAndDexCopyTask implements Runnable { } // get the file name first - int lastIndex = srcSoPath.lastIndexOf('/'); - int length = srcSoPath.length(); - String soFileName = srcSoPath.substring(lastIndex, length); + // int lastIndex = srcSoPath.lastIndexOf('/'); + // int length = srcSoPath.length(); + String soFileName = SO_FILE_NAME_WITH_SUFFIX; // do copy FileUtils.copyFileFromJar(srcSoPath, new File(apkSoParentFile, soFileName).getAbsolutePath()); } - private void deleteMetaInfo() { String metaInfoFilePath = "META-INF"; File metaInfoFileRoot = new File(unzipApkFilePath + metaInfoFilePath); diff --git a/xpatch/src/main/java/com/storm/wind/xpatch/util/ReflectUtils.java b/xpatch/src/main/java/com/storm/wind/xpatch/util/ReflectUtils.java new file mode 100644 index 0000000..8049bcf --- /dev/null +++ b/xpatch/src/main/java/com/storm/wind/xpatch/util/ReflectUtils.java @@ -0,0 +1,356 @@ +package com.storm.wind.xpatch.util; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; + +/** + * Created by xiawanli on 2018/8/25 + */ +public class ReflectUtils { + + //获取类的实例的变量的值 + public static Object getField(Object receiver, String fieldName) { + return getField(null, receiver, fieldName); + } + + //获取类的静态变量的值 + public static Object getField(String className, String fieldName) { + return getField(className, null, fieldName); + } + + public static Object getField(Class clazz, String className, String fieldName, Object receiver) { + try { + if (clazz == null) { + clazz = Class.forName(className); + } + Field field = clazz.getDeclaredField(fieldName); + if (field == null) { + return null; + } + field.setAccessible(true); + return field.get(receiver); + } catch (Throwable e) { + e.printStackTrace(); + } + return null; + } + + private static Object getField(String className, Object receiver, String fieldName) { + Class clazz = null; + Field field; + if (className != null && className.length() > 0) { + try { + clazz = Class.forName(className); + } catch (ClassNotFoundException e) { + e.printStackTrace(); + } + } else { + if (receiver != null) { + clazz = receiver.getClass(); + } + } + if (clazz == null) { + return null; + } + + try { + field = findField(clazz, fieldName); + if (field == null) { + return null; + } + field.setAccessible(true); + return field.get(receiver); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } catch (IllegalArgumentException e) { + e.printStackTrace(); + } catch (NullPointerException e) { + e.printStackTrace(); + } + return null; + } + + public static Object setField(Object receiver, String fieldName, Object value) { + try { + Field field; + field = findField(receiver.getClass(), fieldName); + if (field == null) { + return null; + } + field.setAccessible(true); + Object old = field.get(receiver); + field.set(receiver, value); + return old; + } catch (IllegalAccessException e) { + e.printStackTrace(); + } catch (IllegalArgumentException e) { + e.printStackTrace(); + } + return null; + } + + public static Object setField(Class clazz, Object receiver, String fieldName, Object value) { + try { + Field field; + field = findField(clazz, fieldName); + if (field == null) { + return null; + } + field.setAccessible(true); + Object old = field.get(receiver); + field.set(receiver, value); + return old; + } catch (IllegalAccessException e) { + e.printStackTrace(); + } catch (IllegalArgumentException e) { + e.printStackTrace(); + } + return null; + } + + public static Object callMethod(Object receiver, String methodName, Object... params) { + return callMethod(null, receiver, methodName, params); + } + + public static Object setField(String clazzName, Object receiver, String fieldName, Object value) { + try { + Class clazz = Class.forName(clazzName); + Field field; + field = findField(clazz, fieldName); + if (field == null) { + return null; + } + field.setAccessible(true); + Object old = field.get(receiver); + field.set(receiver, value); + return old; + } catch (IllegalAccessException e) { + e.printStackTrace(); + } catch (IllegalArgumentException e) { + e.printStackTrace(); + } catch (ClassNotFoundException e) { + e.printStackTrace(); + } + return null; + } + + + public static Object callMethod(String className, String methodName, Object... params) { + return callMethod(className, null, methodName, params); + } + + public static Object callMethod(Class clazz, String className, String methodName, Object receiver, + Class[] types, Object... params) { + try { + if (clazz == null) { + clazz = Class.forName(className); + } + Method method = clazz.getDeclaredMethod(methodName, types); + method.setAccessible(true); + return method.invoke(receiver, params); + } catch (Throwable throwable) { + throwable.printStackTrace(); + } + return null; + } + + private static Object callMethod(String className, Object receiver, String methodName, Object... params) { + Class clazz = null; + if (className != null && className.length() > 0) { + try { + clazz = Class.forName(className); + } catch (ClassNotFoundException e) { + e.printStackTrace(); + } + } else { + if (receiver != null) { + clazz = receiver.getClass(); + } + } + if (clazz == null) { + return null; + } + try { + Method method = findMethod(clazz, methodName, params); + if (method == null) { + return null; + } + method.setAccessible(true); + return method.invoke(receiver, params); + } catch (IllegalArgumentException e) { + e.printStackTrace(); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } catch (InvocationTargetException e) { + e.printStackTrace(); + } + return null; + } + + private static Method findMethod(Class clazz, String name, Object... arg) { + Method[] methods = clazz.getMethods(); + Method method = null; + for (Method m : methods) { + if (methodFitParam(m, name, arg)) { + method = m; + break; + } + } + + if (method == null) { + method = findDeclaredMethod(clazz, name, arg); + } + return method; + } + + private static Method findDeclaredMethod(Class clazz, String name, Object... arg) { + Method[] methods = clazz.getDeclaredMethods(); + Method method = null; + for (Method m : methods) { + if (methodFitParam(m, name, arg)) { + method = m; + break; + } + } + + if (method == null) { + if (clazz.equals(Object.class)) { + return null; + } + return findDeclaredMethod(clazz.getSuperclass(), name, arg); + } + return method; + } + + private static boolean methodFitParam(Method method, String methodName, Object... arg) { + if (!methodName.equals(method.getName())) { + return false; + } + + Class[] paramTypes = method.getParameterTypes(); + if (arg == null || arg.length == 0) { + return paramTypes == null || paramTypes.length == 0; + } + if (paramTypes.length != arg.length) { + return false; + } + + for (int i = 0; i < arg.length; ++i) { + Object ar = arg[i]; + Class paramT = paramTypes[i]; + if (ar == null) { + continue; + } + + //TODO for primitive type + if (paramT.isPrimitive()) { + continue; + } + + if (!paramT.isInstance(ar)) { + return false; + } + } + return true; + } + + private static Field findField(Class clazz, String name) { + try { + return clazz.getDeclaredField(name); + } catch (NoSuchFieldException e) { + if (clazz.equals(Object.class)) { + e.printStackTrace(); + return null; + } + Class base = clazz.getSuperclass(); + return findField(base, name); + } + } + + //表示Field或者Class是编译器自动生成的 + private static final int SYNTHETIC = 0x00001000; + //表示Field是final的 + private static final int FINAL = 0x00000010; + //内部类持有的外部类对象一定有这两个属性 + private static final int SYNTHETIC_AND_FINAL = SYNTHETIC | FINAL; + + private static boolean checkModifier(int mod) { + return (mod & SYNTHETIC_AND_FINAL) == SYNTHETIC_AND_FINAL; + } + + //获取内部类实例持有的外部类对象 + public static T getExternalField(Object innerObj) { + return getExternalField(innerObj, null); + } + + /** + * 内部类持有的外部类对象的形式为: + * final Outer this$0; + * flags: ACC_FINAL, ACC_SYNTHETIC + * 参考:https://www.jianshu.com/p/9335c15c43cf + * And:https://www.2cto.com/kf/201402/281879.html + * + * @param innerObj 内部类对象 + * @param name 内部类持有的外部类名称,默认是"this$0" + * @return 内部类持有的外部类对象 + */ + private static T getExternalField(Object innerObj, String name) { + Class clazz = innerObj.getClass(); + if (name == null || name.isEmpty()) { + name = "this$0"; + } + Field field; + try { + field = clazz.getDeclaredField(name); + } catch (NoSuchFieldException e) { + e.printStackTrace(); + return null; + } + field.setAccessible(true); + if (checkModifier(field.getModifiers())) { + try { + return (T) field.get(innerObj); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + return getExternalField(innerObj, name + "$"); + } + + //获取当前对象的泛型类 added by xia wanli + public static Class getParameterizedClassType(Object object) { + Class clazz; + //getGenericSuperclass()获得带有泛型的父类 + //Type是 Java 中所有类型的公共高级接口。包括原始类型、参数化类型、数组类型、类型变量和基本类型。 + Type genericSuperclass = object.getClass().getGenericSuperclass(); + if (genericSuperclass instanceof ParameterizedType) { + //ParameterizedType参数化类型,即泛型 + ParameterizedType parameterizedType = (ParameterizedType) genericSuperclass; + //getActualTypeArguments获取参数化类型的数组,泛型可能有多个 + Type[] actualTypeArguments = parameterizedType.getActualTypeArguments(); + clazz = (Class) actualTypeArguments[0]; + } else { + clazz = (Class) genericSuperclass; + } + return clazz; + } + + //获取当前对象的泛型类 added by xia wanli + public static Type getObjectParameterizedType(Object object) { + //getGenericSuperclass()获得带有泛型的父类 + //Type是 Java 中所有类型的公共高级接口。包括原始类型、参数化类型、数组类型、类型变量和基本类型。 + Type genericSuperclass = object.getClass().getGenericSuperclass(); + if (genericSuperclass instanceof ParameterizedType) { + //ParameterizedType参数化类型,即泛型 + ParameterizedType parameterizedType = (ParameterizedType) genericSuperclass; + //getActualTypeArguments获取参数化类型的数组,泛型可能有多个 + Type[] actualTypeArguments = parameterizedType.getActualTypeArguments(); + return actualTypeArguments[0]; + } else { + throw new RuntimeException("Missing type parameter."); + } + } +} diff --git a/xpatch/src/main/java/com/storm/wind/xpatch/util/ShellCmdUtil.java b/xpatch/src/main/java/com/storm/wind/xpatch/util/ShellCmdUtil.java index db603d1..9a57e82 100644 --- a/xpatch/src/main/java/com/storm/wind/xpatch/util/ShellCmdUtil.java +++ b/xpatch/src/main/java/com/storm/wind/xpatch/util/ShellCmdUtil.java @@ -57,6 +57,29 @@ public class ShellCmdUtil { return result.toString(); } + public static void chmod(String path, int mode) throws Exception { + chmodOnAndroid(path, mode); + + File file = new File(path); + String cmd = "chmod "; + if (file.isDirectory()) { + cmd += " -R "; + } + String cmode = String.format("%o", mode); + Runtime.getRuntime().exec(cmd + cmode + " " + path).waitFor(); + } + + private static void chmodOnAndroid(String path, int mode) { + Object sdk_int = ReflectUtils.getField("android.os.Build$VERSION", "SDK_INT"); + if (!(sdk_int instanceof Integer)) { + return; + } + if ((int)sdk_int >= 21) { + System.out.println("chmod on android is called, path = " + path); + ReflectUtils.callMethod("android.system.Os", "chmod", path, mode); + } + } + private static void close(Closeable stream) { if (stream != null) { try { @@ -66,4 +89,23 @@ public class ShellCmdUtil { } } } + + public interface FileMode { + int MODE_ISUID = 04000; + int MODE_ISGID = 02000; + int MODE_ISVTX = 01000; + int MODE_IRUSR = 00400; + int MODE_IWUSR = 00200; + int MODE_IXUSR = 00100; + int MODE_IRGRP = 00040; + int MODE_IWGRP = 00020; + int MODE_IXGRP = 00010; + int MODE_IROTH = 00004; + int MODE_IWOTH = 00002; + int MODE_IXOTH = 00001; + + int MODE_755 = MODE_IRUSR | MODE_IWUSR | MODE_IXUSR + | MODE_IRGRP | MODE_IXGRP + | MODE_IROTH | MODE_IXOTH; + } }