class ApprovedLoanApplication implements Serializable { private static final long serialVersionUID = 1L; private static final double LEAST_ANNUAL_SALARY = 108000; private final String applicationId; // immutable field private final double annualSalary; // immutable field private final Date endDate; ApprovedLoanApplication( String applicationId, double annualSalary, Date endDate) { if (applicationId == null || applicationId.length() == 0) { throw new IllegalArgumentException( "Applicant Name is required."); } if (annualSalary < LEAST_ANNUAL_SALARY) { throw new IllegalArgumentException( "Least annual salary requirement not met. " + "Loan application rejected."); } if (endDate == null) { throw new IllegalArgumentException("End Date is required."); } this.applicationId = applicationId; this.annualSalary = annualSalary; this.endDate = new Date(endDate.getTime()); //defensive copy } // other methods... }
The ApprovedLoanApplication class above has done a good job in ensuring the object is valid and immutable during object construction. However, once this object is serialized into serialized form, the confidential data like annualSalary is visible even thought it been declared as private. One more issue is that, when the serialized form is deserialized, the same validation rules would not applied as the deserialization process will never call the class constructor in order to re-constitute back the object. This is one of the most important behavior of deserialization that we must aware of and react on it. It is causing the ApprovedLoanApplication facing security risks of data integrity breaking as the validated data could be hijacked and replaced with
- modified data. For example, the attacker changes the mutable endDate to extends the loan period,
- invalid data. For example, the attacker injects null applicationId which could cause system behave abnormally.
Encrypt serializable fields
To against unauthorized disclosure attack, we have to encrypt the confidential data before it is serialized. Encrypted data will be decrypted later during the deserialization process. Add the following methods into ApprovedLoanApplication.
private int encrypt(int figure) { // Encryption algorithm. // Could be replaced by complex encryption algorithm. return figure << 2; } private int decrypt(int figure) { return figure >> 2; // Decryption algorithm. } private void writeObject(ObjectOutputStream out) throws IOException { this.annualSalary = encrypt(annualSalary); // encrypt out.defaultWriteObject(); } private void readObject (ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); this.annualSalary = decrypt(annualSalary); //decrypted }
Defensive copy
To against data modification attack, we have to perform defensive copy on our mutable object. Add the following code line into readObject() method.
this.endDate = new Date(endDate.getTime()); // defensive copy
Invariant checking
To against invariant breaking attack, we have to validate the data come from deserialization. As I mentioned earlier, deserialization does not invoke class constructor but instead, it will for sure invoke readObject() method, which give us a chance of managing the incoming stream data. Which means that we can do the same validation in the readObject() method. We may consider readObject() method is a constructor in serialization world. However, in this example, instead of duplicate the validation codes, I've offloaded the validation logic to a validator class which implements java.io.ObjectInputValidation. The validateObject() callback method will be called during deserialization runtime. Below is the full version after combining with data encryption and defensive copy techniques.
class ApprovedLoanApplication implements Serializable { private static final long serialVersionUID = 1L; private final String applicationId; private int annualSalary; // can't be final due to encryption private Date endDate; // can't be final due to defensive copy //validator need not to be serialized private final transient ApprovedLoanApplicationValidator validator; ApprovedLoanApplication( String applicationId, int annualSalary, Date endDate) { this.validator = new ApprovedLoanApplicationValidator(applicationId, annualSalary, endDate); try { this.validator.validateObject(); // call the same validation logic } catch (InvalidObjectException ex) { throw new IllegalArgumentException(ex.getMessage()); } this.applicationId = applicationId; this.annualSalary = annualSalary; this.endDate = new Date(endDate.getTime()); } private int encrypt(int figure) { return figure << 2; } private int decrypt(int figure) { return figure >> 2; } private void writeObject(ObjectOutputStream out) throws IOException { this.annualSalary = encrypt(annualSalary); out.defaultWriteObject(); } private void readObject (ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); this.annualSalary = decrypt(annualSalary); this.endDate = new Date(endDate.getTime()); in.registerValidation(new ApprovedLoanApplicationValidator( applicationId, annualSalary, endDate), 0); //register validator } // other methods... // Validator inner class which implements the validation logic private final class ApprovedLoanApplicationValidator implements ObjectInputValidation { private static final int LEAST_ANNUAL_SALARY = 108000; private final String applicationId; private final int annualSalary; private final Date endDate; private ApprovedLoanApplicationValidator( String applicationId, int annualSalary, Date endDate) { this.applicationId = applicationId; this.annualSalary = annualSalary; this.endDate = endDate; } @Override public void validateObject() throws InvalidObjectException { if (applicationId == null || applicationId.length() == 0) { throw new IllegalArgumentException( "Applicant Name is required."); } if (annualSalary < LEAST_ANNUAL_SALARY) { throw new IllegalArgumentException( "Least annual salary requirement not met." + "Loan application rejected."); } if (endDate == null) { throw new IllegalArgumentException("End Date is required."); } } } }
Serialization Proxy
There is another way to prevent data integrity breaking by implementing serialization proxy pattern. This implementation basically takes the advantage of the fact that serialization allow us to serialize replacement object instead of the real object.
class ApprovedLoanApplication implements Serializable { private static final long serialVersionUID = 1L; private static final double LEAST_ANNUAL_SALARY = 108000; private final String applicationId; private final int annualSalary; // maintain immutable private final Date endDate; // maintain immutable ApprovedLoanApplication( String applicationId, int annualSalary, Date endDate) { if (applicationId == null || applicationId.length() == 0) { throw new IllegalArgumentException( "Applicant Name is required."); } if (annualSalary < LEAST_ANNUAL_SALARY) { throw new IllegalArgumentException( "Least annual salary requirement not met." + "Loan application rejected."); } if (endDate == null) { throw new IllegalArgumentException("End Date is required."); } this.applicationId = applicationId; this.annualSalary = annualSalary; this.endDate = new Date(endDate.getTime()); } private Object writeReplace() throws ObjectStreamException { // serialize replacement object return new SerializationProxy(applicationId, annualSalary, endDate); } private void readObject(ObjectInputStream in) throws InvalidObjectException { // Read data from stream in order to construct object's // fields in this method is no longer valid throw new InvalidObjectException("Proxy required."); } // other methods... // static inner serializable proxy class private static class SerializationProxy implements Serializable { private static final long serialVersionUID = 1L; private final String applicationId; private final int annualSalary; private final Date endDate; private SerializationProxy( String applicationId, int annualSalary, Date endDate) { this.applicationId = applicationId; this.annualSalary = encrypt(annualSalary); this.endDate = endDate; } private Object readResolve() throws ObjectStreamException { // return a new valid object return new ApprovedLoanApplication( applicationId, decrypt(annualSalary), endDate); } private int encrypt(int figure) { return figure << 2; } private int decrypt(int figure) { return figure >> 2; } } }
As you may aware that, using Serialization Proxy pattern, we mostly maintain our original code. readResolve() method of a proxy class will create a new ApprovedLoanApplication object by calling the same class constructor, where the defensive copy and invariant checking are resided. Moreover, we could maintain the final declaration for ApprovedLoanApplication fields, which is not possible in the earlier techniques. This is very helpful because we could concentrate and remain on our class implementation instead of revising it to handle security in serialization.
In short, we should aware of the fact that, serialization does not provide security on itself. I even doubt that those techniques above may not enough to secure our objects, but at least those are best practices we should apply especially our serialized form contains sensitive information.
Reference:
Book: Effective Java 2nd Edition by Joshua Blosh
No comments:
Post a Comment