In a project that's being obfuscated with ProGuard (using the Gradle plugin), I have lots of serialization/deserialization using Jackson. I've found that in the obfuscated builds, certain kinds of serialization of polymorphic types is omitting the type information (specified with @JsonTypeInfo(use = Id.NAME, include = As.PROPERTY, property = "type")
), which causes some very strange behavior during deserialization.
For example, here's the (correct) serialized form when run with unobfuscated code:
{
"owner" : "Jay Leno",
"cars" : [ {
"type" : "Corvette",
"name" : "Corvette",
"year" : 1963
}, {
"type" : "Aztek",
"name" : "Ugly",
"year" : 2003
} ]
}
Here's the output of the exact same code after running through ProGuard:
{
"owner" : "Jay Leno",
"cars" : [ {
"a" : 1963,
"name" : "Corvette"
}, {
"a" : 2003,
"name" : "Ugly"
} ]
}
Note the "type"
property is missing from the array elements in the obfuscated form. This causes Jackson to deserialize those objects incorrectly.
Here is an MRE of the code; sorry for including so much code in a question, but this is as minimal as I could get it and still demonstrate the context and problem.
Also viewable as a gist, or downloadable ZIP of the project.
@JsonAutoDetect(fieldVisibility = Visibility.ANY)
@JsonTypeInfo(use = Id.NAME, include = As.PROPERTY, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = Corvette.class),
@JsonSubTypes.Type(value = Aztek.class)
})
public abstract class CarModel {
public abstract String getName();
public abstract int getYear();
}
@Getter
@AllArgsConstructor
@ToString
public class Corvette extends CarModel {
private int year;
@JsonCreator
public Corvette(@JsonProperty("name") String name, @JsonProperty("year") int year) {
this.year = year;
}
@Override
@ToString.Include
public String getName() {
return "Corvette";
}
}
@Getter
@Setter
@AllArgsConstructor
@ToString
public class Aztek extends CarModel {
private int year;
@JsonCreator
public Aztek(@JsonProperty("name") String name, @JsonProperty("year") int year) {
this.year = year;
}
@Override
@ToString.Include
public String getName() {
return "Ugly";
}
}
@Getter
public class Inventory {
private String owner;
private List<CarModel> cars;
@JsonCreator
public Inventory(@JsonProperty("owner") String owner, @JsonProperty("cars") List<CarModel> cars) {
this.owner = owner;
this.cars = cars;
}
@Override
public String toString() {
StringBuilder builder = new StringBuilder("Inventory [");
builder.append("owner=")
.append(owner)
.append(", cars=<");
System.out.println("First cars element is of type " + cars.get(0).getClass());
cars.forEach(car -> builder.append(car).append(", "));
builder.append(">");
builder.append("]");
return builder.toString();
}
}
public class JacksonPolymorphicSerialization {
private final static ObjectMapper JSONMapper =
JsonMapper.builder()
.configure(DEFAULT_VIEW_INCLUSION, false)
.configure(FAIL_ON_UNKNOWN_PROPERTIES, false)
.serializationInclusion(Include.NON_ABSENT)
.build();
private static final ObjectReader JSONReader = JSONMapper.readerFor(Inventory.class);
private static final ObjectWriter JSONWriter = JSONMapper.writerFor(Inventory.class).withDefaultPrettyPrinter();
public static void main(String[] args) throws JacksonException {
Corvette corvette = new Corvette(1963);
Aztek aztek = new Aztek(2003);
Inventory inventory = new Inventory("Jay Leno", List.of(corvette, aztek));
String json = JSONWriter.writeValueAsString(inventory);
System.out.println("Serialized form:\n" + json);
System.out.println();
inventory = JSONReader.readValue(json);
System.out.println("Deserialized object: " + inventory);
}
}
Here's the Gradle build file
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath 'com.guardsquare:proguard-gradle:7.6.1'
}
}
plugins {
id 'java'
id "io.freefair.lombok" version "8.12.1"
}
repositories {
mavenCentral()
}
def appMainClass = 'rizzo.test.JacksonPolymorphicSerialization'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
jar {
duplicatesStrategy = 'exclude'
manifest {
attributes 'Main-Class': "${appMainClass}"
}
from {
configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
}
}
dependencies {
implementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.18.2'
implementation group: 'com.fasterxml.jackson.core', name: 'jackson-annotations', version: '2.18.2'
}
task proguard(type: proguard.gradle.ProGuardTask) {
dependsOn classes
verbose
printmapping "${buildDir}/proguard-mapping.txt"
injars "${buildDir}/classes/java/main"
outjars "${buildDir}/classes/obfuscated/"
libraryjars "${System.getProperty('java.home')}/jmods/java.base.jmod", jarfilter: '!**.jar', filter: '!module-info.class'
libraryjars "${System.getProperty('java.home')}/jmods/java.logging.jmod", jarfilter: '!**.jar', filter: '!module-info.class'
libraryjars "${System.getProperty('java.home')}/jmods/java.desktop.jmod", jarfilter: '!**.jar', filter: '!module-info.class'
libraryjars "${System.getProperty('java.home')}/jmods/java.xml.jmod", jarfilter: '!**.jar', filter: '!module-info.class'
libraryjars "${System.getProperty('java.home')}/jmods/java.sql.jmod", jarfilter: '!**.jar', filter: '!module-info.class'
// This will contain the app dependencies.
libraryjars sourceSets.main.compileClasspath
keepdirectories
// Preserve getters and setters
keepclassmembers 'class * { \
** get*(); \
void set*(***); \
}'
// Keep the main class entry point.
keep "public class ${appMainClass} { \
public static void main(java.lang.String[]); \
}"
// This helps produce useful stack traces (see https://www.guardsquare.com/manual/configuration/examples#stacktrace)
renamesourcefileattribute 'SourceFile'
keepattributes '*Annotation*,EnclosingMethod,SourceFile,LineNumberTable'
doLast {
delete "${buildDir}/classes/java/main"
copy {
from "${buildDir}/classes/obfuscated/"
into "${buildDir}/classes/java/main"
}
}
}
Run ./gradlew jar
to build, you'll get a normal (unobfuscated) JAR. Run ./gradlew proguard jar
and the resulting JAR will be obfuscated. Either way, you can then run with java -jar ...
Here's what happens with each.
Unobfuscated:
> java -jar build/libs/ProguardJacksonTest.jar
Serialized form:
{
"owner" : "Jay Leno",
"cars" : [ {
"type" : "Corvette",
"name" : "Corvette",
"year" : 1963
}, {
"type" : "Aztek",
"name" : "Ugly",
"year" : 2003
} ]
}
First cars element is of type class rizzo.test.Corvette
Deserialized object: Inventory [owner=Jay Leno, cars=<Corvette(year=1963, getName=Corvette), Aztek(year=2003, getName=Ugly), >]
Output using the obfuscated build:
> java -jar build/libs/ProguardJacksonTest.jar
Serialized form:
{
"owner" : "Jay Leno",
"cars" : [ {
"a" : 1963,
"name" : "Corvette"
}, {
"a" : 2003,
"name" : "Ugly"
} ]
}
Exception in thread "main" java.lang.ClassCastException: class java.util.LinkedHashMap cannot be cast to class rizzo.test.b (java.util.LinkedHashMap is in module java.base of loader 'bootstrap'; rizzo.test.b is in unnamed module of loader 'app')
at rizzo.test.d.toString(SourceFile:29)
at java.base/java.lang.StringConcatHelper.stringOf(StringConcatHelper.java:453)
at java.base/java.lang.StringConcatHelper.simpleConcat(StringConcatHelper.java:408)
at rizzo.test.JacksonPolymorphicSerialization.main(SourceFile:38)
The ClassCastException
indicates that the cars
List is actually populated with LinkedHashMap instances rather than CarModel instances as it should. But I guess that makes sense given the serialized form that's missing type information - I suppose Jackson is just deserializing that into maps since it doesn't have the actual expected type at runtime (thanks, stoopid type erasure!).
The real question is, why is Jackson omitting the type info from the serialized form when the code has been obfuscated, and what can I do about it?
The solution was small but subtle. To make code like this example work correctly after obfuscation you must instruct ProGuard to keep the "Signature" data of the bytecode.
Changing this line from build.gradle
:
keepattributes '*Annotation*,EnclosingMethod,SourceFile,LineNumberTable'
to this:
keepattributes '*Annotation*,EnclosingMethod,Signature,SourceFile,LineNumberTable'
allows the example to run successfully even after obfuscation. The description of keepattributes
values is here.