Skip to content

Commit 0750e19

Browse files
committed
j3o hardening
1 parent bc530dd commit 0750e19

8 files changed

Lines changed: 1017 additions & 231 deletions

File tree

.github/workflows/j3o-scan.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: J3O Scan
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- '**/*.j3o'
7+
- 'jme3-desktop/src/main/resources/com/jme3/system/j3o-baseline.txt'
8+
- 'jme3-desktop/src/main/java/com/jme3/system/J3OScanner.java'
9+
- 'jme3-desktop/build.gradle'
10+
- '.github/workflows/j3o-scan.yml'
11+
push:
12+
branches:
13+
- master
14+
- main
15+
paths:
16+
- '**/*.j3o'
17+
- 'jme3-desktop/src/main/resources/com/jme3/system/j3o-baseline.txt'
18+
- 'jme3-desktop/src/main/java/com/jme3/system/J3OScanner.java'
19+
- 'jme3-desktop/build.gradle'
20+
- '.github/workflows/j3o-scan.yml'
21+
22+
permissions:
23+
contents: read
24+
25+
jobs:
26+
scan-j3o:
27+
name: Validate J3O assets
28+
runs-on: ubuntu-latest
29+
steps:
30+
- name: Checkout repository
31+
uses: actions/checkout@v4
32+
33+
- name: Set up Java
34+
uses: actions/setup-java@v4
35+
with:
36+
distribution: temurin
37+
java-version: '17'
38+
39+
- name: Scan J3O assets
40+
run: ./gradlew :jme3-desktop:scanJ3O --console=plain
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright (c) 2009-2026 jMonkeyEngine
3+
* All rights reserved.
4+
*
5+
* Redistribution and use in source and binary forms, with or without
6+
* modification, are permitted provided that the following conditions are
7+
* met:
8+
*
9+
* * Redistributions of source code must retain the above copyright
10+
* notice, this list of conditions and the following disclaimer.
11+
*
12+
* * Redistributions in binary form must reproduce the above copyright
13+
* notice, this list of conditions and the following disclaimer in the
14+
* documentation and/or other materials provided with the distribution.
15+
*
16+
* * Neither the name of 'jMonkeyEngine' nor the names of its contributors
17+
* may be used to endorse or promote products derived from this software
18+
* without specific prior written permission.
19+
*
20+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
22+
* TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
23+
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
24+
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
25+
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
26+
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
27+
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
28+
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
29+
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
30+
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31+
*/
32+
package com.jme3.export;
33+
34+
/**
35+
* Policy hook used before an importer instantiates classes named by a
36+
* serialized asset.
37+
*/
38+
public interface SavableClassFilter {
39+
40+
/** Compatibility policy that allows every class name. */
41+
SavableClassFilter ACCEPT_ALL = new SavableClassFilter() {
42+
@Override
43+
public boolean isAllowed(String className) {
44+
return true;
45+
}
46+
};
47+
48+
/**
49+
* @param className fully-qualified binary class name after legacy remapping
50+
* @return true if the class may be instantiated
51+
*/
52+
boolean isAllowed(String className);
53+
}

jme3-core/src/main/java/com/jme3/export/SavableClassUtil.java

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ private static void addRemapping(String oldClass, Class<? extends Savable> newCl
8686
private SavableClassUtil() {
8787
}
8888

89-
private static String remapClass(String className) throws ClassNotFoundException {
89+
public static String remapClass(String className) {
9090
String result = CLASS_REMAPPINGS.get(className);
9191
if (result == null) {
9292
return className;
@@ -182,7 +182,32 @@ public static int getSavedSavableVersion(Object savable,
182182
public static Savable fromName(String className)
183183
throws ClassNotFoundException, IllegalAccessException,
184184
InstantiationException, InvocationTargetException {
185+
return fromName(className, SavableClassFilter.ACCEPT_ALL);
186+
}
187+
188+
/**
189+
* Creates a new Savable after checking the remapped class name against the
190+
* supplied filter.
191+
*
192+
* @param className the class name to create.
193+
* @param classFilter policy controlling whether the class may be loaded.
194+
* @return the Savable instance of the class.
195+
* @throws InstantiationException thrown if the class does not have an empty constructor.
196+
* @throws IllegalAccessException thrown if the class is not accessible.
197+
* @throws InvocationTargetException if the underlying constructor throws an exception
198+
* @throws ClassNotFoundException thrown if the class name is not in the classpath.
199+
* @throws SecurityException thrown if the filter rejects the class name.
200+
*/
201+
public static Savable fromName(String className, SavableClassFilter classFilter)
202+
throws ClassNotFoundException, IllegalAccessException,
203+
InstantiationException, InvocationTargetException {
185204
className = remapClass(className);
205+
if (classFilter == null) {
206+
throw new NullPointerException("classFilter");
207+
}
208+
if (!classFilter.isAllowed(className)) {
209+
throw new SecurityException("Savable class rejected by filter: " + className);
210+
}
186211

187212
Constructor noArgConstructor = findNoArgConstructor(className);
188213
noArgConstructor.setAccessible(true);
@@ -241,6 +266,9 @@ public static Savable fromName(String className, List<ClassLoader> loaders) thro
241266
private static Constructor findNoArgConstructor(String className)
242267
throws ClassNotFoundException, InstantiationException {
243268
Class clazz = Class.forName(className);
269+
if (!SavableClassUtil.isImplementingSavable(clazz)) {
270+
throw new InstantiationException("Class " + className + " does not implement Savable.");
271+
}
244272
Constructor result;
245273
try {
246274
result = clazz.getDeclaredConstructor();

jme3-core/src/plugins/java/com/jme3/export/binary/BinaryImporter.java

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ public final class BinaryImporter implements JmeImporter {
5252
.getName());
5353

5454
private AssetManager assetManager;
55+
private SavableClassFilter classFilter = SavableClassFilter.ACCEPT_ALL;
5556

5657
//Key - alias, object - bco
5758
private final HashMap<String, BinaryClassObject> classes
@@ -99,6 +100,27 @@ public AssetManager getAssetManager(){
99100
return assetManager;
100101
}
101102

103+
/**
104+
* Sets the policy used before this importer instantiates classes named by
105+
* a J3O file. The default accepts all classes for backward compatibility.
106+
* Use a restrictive filter when loading assets from untrusted sources.
107+
*
108+
* @param classFilter the filter to apply
109+
*/
110+
public void setClassFilter(SavableClassFilter classFilter) {
111+
if (classFilter == null) {
112+
throw new NullPointerException("classFilter");
113+
}
114+
this.classFilter = classFilter;
115+
}
116+
117+
/**
118+
* @return the current class filter
119+
*/
120+
public SavableClassFilter getClassFilter() {
121+
return classFilter;
122+
}
123+
102124
@Override
103125
public Object load(AssetInfo info){
104126
// if (!(info.getKey() instanceof ModelKey))
@@ -145,6 +167,9 @@ public Savable load(InputStream is, ReadListener listener, ByteArrayOutputStream
145167
formatVersion = ByteUtils.readInt(bis);
146168
numClasses = ByteUtils.readInt(bis);
147169

170+
if (formatVersion < 0) {
171+
throw new IOException("Invalid J3O format version: " + formatVersion);
172+
}
148173
// check if this binary is from the future
149174
if (formatVersion > FormatVersion.VERSION){
150175
throw new IOException("The binary file is of newer version than expected! " +
@@ -161,6 +186,9 @@ public Savable load(InputStream is, ReadListener listener, ByteArrayOutputStream
161186
}
162187

163188
int bytes = 4;
189+
if (numClasses <= 0) {
190+
throw new IOException("Invalid J3O class count: " + numClasses);
191+
}
164192
aliasWidth = ((int)FastMath.log(numClasses, 256) + 1);
165193

166194
classes.clear();
@@ -170,7 +198,7 @@ public Savable load(InputStream is, ReadListener listener, ByteArrayOutputStream
170198
// jME3 NEW: Read class version number
171199
int[] classHierarchyVersions;
172200
if (formatVersion >= 1){
173-
int classHierarchySize = bis.read();
201+
int classHierarchySize = readUnsignedByte(bis, "class hierarchy size");
174202
classHierarchyVersions = new int[classHierarchySize];
175203
for (int j = 0; j < classHierarchySize; j++){
176204
classHierarchyVersions[j] = ByteUtils.readInt(bis);
@@ -181,23 +209,29 @@ public Savable load(InputStream is, ReadListener listener, ByteArrayOutputStream
181209

182210
// read classname and classname size
183211
int classLength = ByteUtils.readInt(bis);
212+
checkLength(classLength);
184213
String className = readString(bis, classLength);
214+
if (!classFilter.isAllowed(SavableClassUtil.remapClass(className))) {
215+
throw new IOException("J3O class rejected by filter: " + className);
216+
}
185217

186218
BinaryClassObject bco = new BinaryClassObject();
187219
bco.alias = alias.getBytes();
188220
bco.className = className;
189221
bco.classHierarchyVersions = classHierarchyVersions;
190222

191223
int fields = ByteUtils.readInt(bis);
224+
checkLength(fields);
192225
bytes += (8 + aliasWidth + classLength);
193226

194227
bco.nameFields = new HashMap<String, BinaryClassField>(fields);
195228
bco.aliasFields = new HashMap<Byte, BinaryClassField>(fields);
196229
for (int x = 0; x < fields; x++) {
197-
byte fieldAlias = (byte)bis.read();
198-
byte fieldType = (byte)bis.read();
230+
byte fieldAlias = (byte) readUnsignedByte(bis, "field alias");
231+
byte fieldType = (byte) readUnsignedByte(bis, "field type");
199232

200233
int fieldNameLength = ByteUtils.readInt(bis);
234+
checkLength(fieldNameLength);
201235
String fieldName = readString(bis, fieldNameLength);
202236
BinaryClassField bcf = new BinaryClassField(fieldName, fieldAlias, fieldType);
203237
bco.nameFields.put(fieldName, bcf);
@@ -209,13 +243,17 @@ public Savable load(InputStream is, ReadListener listener, ByteArrayOutputStream
209243
if (listener != null) listener.readBytes(bytes);
210244

211245
int numLocs = ByteUtils.readInt(bis);
246+
checkLength(numLocs);
212247
bytes = 4;
213248

214249
capsuleTable.clear();
215250
locationTable.clear();
216251
for(int i = 0; i < numLocs; i++) {
217252
int id = ByteUtils.readInt(bis);
218253
int loc = ByteUtils.readInt(bis);
254+
if (loc < 0) {
255+
throw new IOException("Invalid negative J3O object location: " + loc);
256+
}
219257
locationTable.put(id, loc);
220258
bytes += 8;
221259
}
@@ -290,15 +328,20 @@ public InputCapsule getCapsule(Savable id) {
290328
}
291329

292330
protected String readString(InputStream f, int length) throws IOException {
331+
checkLength(length);
293332
byte[] data = new byte[length];
294333
for(int j = 0; j < length; j++) {
295-
data[j] = (byte)f.read();
334+
data[j] = (byte) readUnsignedByte(f, "string");
296335
}
297336

298337
return new String(data);
299338
}
300339

301340
protected String readString(int length, int offset) throws IOException {
341+
checkLength(length);
342+
if (offset < 0 || offset + length < offset || offset + length > dataArray.length) {
343+
throw new IOException("String outside J3O payload: offset=" + offset + ", length=" + length);
344+
}
302345
byte[] data = new byte[length];
303346
for(int j = 0; j < length; j++) {
304347
data[j] = dataArray[j+offset];
@@ -307,14 +350,35 @@ protected String readString(int length, int offset) throws IOException {
307350
return new String(data);
308351
}
309352

353+
private void checkLength(int length) throws IOException {
354+
if (length < 0) {
355+
throw new IOException("Invalid negative J3O length/count: " + length);
356+
}
357+
}
358+
359+
private int readUnsignedByte(InputStream input, String label) throws IOException {
360+
int value = input.read();
361+
if (value < 0) {
362+
throw new EOFException("Unexpected end of J3O while reading " + label);
363+
}
364+
return value;
365+
}
366+
310367
public Savable readObject(int id) {
311368

312369
if(contentTable.get(id) != null) {
313370
return contentTable.get(id);
314371
}
315372

316373
try {
317-
int loc = locationTable.get(id);
374+
Integer objectLocation = locationTable.get(id);
375+
if (objectLocation == null) {
376+
throw new IOException("Missing J3O object location for id: " + id);
377+
}
378+
int loc = objectLocation;
379+
if (loc < 0 || loc >= dataArray.length) {
380+
throw new IOException("J3O object location outside payload: " + loc);
381+
}
318382

319383
String alias = readString(aliasWidth, loc);
320384
loc+=aliasWidth;
@@ -326,10 +390,16 @@ public Savable readObject(int id) {
326390
return null;
327391
}
328392

393+
if (loc + 4 > dataArray.length) {
394+
throw new IOException("Truncated J3O object length at payload offset: " + loc);
395+
}
329396
int dataLength = ByteUtils.convertIntFromBytes(dataArray, loc);
330397
loc+=4;
398+
if (dataLength < 0 || loc + dataLength < loc || loc + dataLength > dataArray.length) {
399+
throw new IOException("Invalid J3O object data length: " + dataLength);
400+
}
331401

332-
Savable out = SavableClassUtil.fromName(bco.className);
402+
Savable out = SavableClassUtil.fromName(bco.className, classFilter);
333403

334404
BinaryInputCapsule cap = new BinaryInputCapsule(this, out, bco);
335405
cap.setContent(dataArray, loc, loc+dataLength);
@@ -348,4 +418,4 @@ public Savable readObject(int id) {
348418
return null;
349419
}
350420
}
351-
}
421+
}

0 commit comments

Comments
 (0)