Skip to content

Commit 29d7d20

Browse files
BarbatosBarbatos
authored andcommitted
fix(plugins): close TOCTOU window in keystore directory scans
The plugins CLI scans (KeystoreList, KeystoreImport.findExistingKeystore, KeystoreUpdate.findKeystoreByAddress) and the second keystore read in KeystoreUpdate.call previously combined a NOFOLLOW lstat check with a subsequent MAPPER.readValue(file, ...). Jackson's File overload uses FileInputStream → open(2) without O_NOFOLLOW, so an attacker who can write in the keystore directory could swap a regular file for a symlink between the stat and the read. Replace the check-then-use pattern with a single read that pushes the NOFOLLOW flag down to the open syscall: - New KeystoreCliUtils.readKeystoreFile(File, PrintWriter) opens via Files.newByteChannel(path, READ, NOFOLLOW_LINKS), reads at most MAX_KEYSTORE_SIZE (8 KiB) bytes, and returns the byte[] for the caller to feed into MAPPER.readValue(byte[], Class) — no second file handle, no follow-symlink window. - Drop isSafeRegularFile (now subsumed by readKeystoreFile). - All four call sites switched: list, import duplicate scan, update lookup scan, and update's re-read after lookup at KeystoreUpdate .call line 141. Also adds an explicit oversized-file warning ("skipping oversized file") so a hostile directory of large planted files surfaces clearly rather than silently consuming memory. Note: this hardens the plugins CLI scenario specifically. The SR startup path through WalletUtils.loadCredentials uses a different policy (warn + follow, see commit df91f45) — that path explicitly configures a single keystore file, parallel to Lighthouse's voting_keystore_path, where symlink support is part of the contract.
1 parent df91f45 commit 29d7d20

4 files changed

Lines changed: 78 additions & 25 deletions

File tree

plugins/src/main/java/common/org/tron/plugins/KeystoreCliUtils.java

Lines changed: 60 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ final class KeystoreCliUtils {
2929

3030
private static final long MAX_FILE_SIZE = 1024;
3131

32+
/**
33+
* Cap on the size of a single keystore JSON read during directory scans.
34+
* Standard V3 keystores are ~500–700 bytes; 8 KiB leaves headroom for
35+
* unusual scrypt parameter combinations while bounding the memory cost
36+
* of scanning a hostile directory of planted oversized files.
37+
*/
38+
static final long MAX_KEYSTORE_SIZE = 8 * 1024;
39+
3240
private KeystoreCliUtils() {
3341
}
3442

@@ -202,31 +210,65 @@ static boolean checkFileExists(File file, String label, PrintWriter err) {
202210
}
203211

204212
/**
205-
* Returns true iff {@code file} exists, is not a symbolic link, and is a
206-
* regular file (not a directory, FIFO, or device). Used to filter keystore
207-
* directory scans before {@code MAPPER.readValue(file, ...)} so a hostile
208-
* or group-writable keystore directory cannot redirect reads to arbitrary
209-
* files (e.g. {@code /etc/shadow}) or block on non-regular files
210-
* (e.g. a FIFO) via planted entries.
213+
* Read the bytes of a keystore-directory entry, refusing to follow
214+
* symbolic links and rejecting non-regular files. Returns {@code null}
215+
* (with a warning to {@code err}) when the entry should be skipped.
211216
*
212-
* <p>Writes a warning to {@code err} when the entry is skipped.
217+
* <p>Unlike {@code Files.readAttributes(...) + MAPPER.readValue(file, ...)},
218+
* this opens the channel with {@link LinkOption#NOFOLLOW_LINKS} so the
219+
* {@code O_NOFOLLOW} flag is enforced atomically by the kernel at
220+
* {@code open(2)} — closing the TOCTOU window between an lstat-style
221+
* check and a follow-symlink {@code FileInputStream} open. The caller
222+
* then deserializes the bytes via {@code ObjectMapper.readValue(byte[],
223+
* Class)}.
224+
*
225+
* <p>Files larger than {@link #MAX_KEYSTORE_SIZE} are skipped to bound
226+
* memory cost when scanning a hostile or oversized directory.
213227
*/
214-
static boolean isSafeRegularFile(File file, PrintWriter err) {
228+
static byte[] readKeystoreFile(File file, PrintWriter err) {
229+
Path path = file.toPath();
230+
BasicFileAttributes attrs;
215231
try {
216-
BasicFileAttributes attrs = Files.readAttributes(file.toPath(),
217-
BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
218-
if (attrs.isSymbolicLink()) {
219-
err.println("Warning: skipping symbolic link: " + file.getName());
220-
return false;
232+
attrs = Files.readAttributes(path, BasicFileAttributes.class,
233+
LinkOption.NOFOLLOW_LINKS);
234+
} catch (IOException e) {
235+
err.println("Warning: skipping unreadable file: " + file.getName());
236+
return null;
237+
}
238+
if (attrs.isSymbolicLink()) {
239+
err.println("Warning: skipping symbolic link: " + file.getName());
240+
return null;
241+
}
242+
if (!attrs.isRegularFile()) {
243+
err.println("Warning: skipping non-regular file: " + file.getName());
244+
return null;
245+
}
246+
if (attrs.size() > MAX_KEYSTORE_SIZE) {
247+
err.println("Warning: skipping oversized file (>" + MAX_KEYSTORE_SIZE
248+
+ " bytes): " + file.getName());
249+
return null;
250+
}
251+
252+
int size = (int) attrs.size();
253+
java.util.Set<OpenOption> openOptions = new HashSet<>();
254+
openOptions.add(StandardOpenOption.READ);
255+
openOptions.add(LinkOption.NOFOLLOW_LINKS);
256+
try (SeekableByteChannel ch = Files.newByteChannel(path, openOptions)) {
257+
ByteBuffer buf = ByteBuffer.allocate(size);
258+
while (buf.hasRemaining()) {
259+
if (ch.read(buf) < 0) {
260+
break;
261+
}
221262
}
222-
if (!attrs.isRegularFile()) {
223-
err.println("Warning: skipping non-regular file: " + file.getName());
224-
return false;
263+
if (buf.position() < size) {
264+
byte[] actual = new byte[buf.position()];
265+
System.arraycopy(buf.array(), 0, actual, 0, buf.position());
266+
return actual;
225267
}
226-
return true;
268+
return buf.array();
227269
} catch (IOException e) {
228270
err.println("Warning: skipping unreadable file: " + file.getName());
229-
return false;
271+
return null;
230272
}
231273
}
232274

plugins/src/main/java/common/org/tron/plugins/KeystoreImport.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,11 +169,12 @@ private String findExistingKeystore(File dir, String address, PrintWriter err) {
169169
com.fasterxml.jackson.databind.ObjectMapper mapper =
170170
KeystoreCliUtils.mapper();
171171
for (File file : files) {
172-
if (!KeystoreCliUtils.isSafeRegularFile(file, err)) {
172+
byte[] bytes = KeystoreCliUtils.readKeystoreFile(file, err);
173+
if (bytes == null) {
173174
continue;
174175
}
175176
try {
176-
WalletFile wf = mapper.readValue(file, WalletFile.class);
177+
WalletFile wf = mapper.readValue(bytes, WalletFile.class);
177178
if (KeystoreCliUtils.isValidKeystoreFile(wf)
178179
&& address.equals(wf.getAddress())) {
179180
return file.getName();

plugins/src/main/java/common/org/tron/plugins/KeystoreList.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,12 @@ public Integer call() {
5959

6060
List<Map<String, String>> entries = new ArrayList<>();
6161
for (File file : files) {
62-
if (!KeystoreCliUtils.isSafeRegularFile(file, err)) {
62+
byte[] bytes = KeystoreCliUtils.readKeystoreFile(file, err);
63+
if (bytes == null) {
6364
continue;
6465
}
6566
try {
66-
WalletFile walletFile = MAPPER.readValue(file, WalletFile.class);
67+
WalletFile walletFile = MAPPER.readValue(bytes, WalletFile.class);
6768
if (!KeystoreCliUtils.isValidKeystoreFile(walletFile)) {
6869
continue;
6970
}

plugins/src/main/java/common/org/tron/plugins/KeystoreUpdate.java

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,15 @@ public Integer call() {
138138
}
139139

140140
boolean ecKey = !sm2;
141-
WalletFile walletFile = MAPPER.readValue(keystoreFile, WalletFile.class);
141+
// Re-read via NOFOLLOW byte channel to close the TOCTOU window between
142+
// findKeystoreByAddress and this read — an attacker with directory
143+
// write access could otherwise swap the file for a symlink in between.
144+
byte[] keystoreBytes = KeystoreCliUtils.readKeystoreFile(keystoreFile, err);
145+
if (keystoreBytes == null) {
146+
// readKeystoreFile already printed the specific reason
147+
return 1;
148+
}
149+
WalletFile walletFile = MAPPER.readValue(keystoreBytes, WalletFile.class);
142150
SignInterface keyPair = Wallet.decrypt(oldPassword, walletFile, ecKey);
143151

144152
// createStandard already sets the correctly-derived address. Do NOT override
@@ -199,11 +207,12 @@ private File findKeystoreByAddress(String targetAddress, PrintWriter err) {
199207
}
200208
java.util.List<File> matches = new java.util.ArrayList<>();
201209
for (File file : files) {
202-
if (!KeystoreCliUtils.isSafeRegularFile(file, err)) {
210+
byte[] bytes = KeystoreCliUtils.readKeystoreFile(file, err);
211+
if (bytes == null) {
203212
continue;
204213
}
205214
try {
206-
WalletFile wf = MAPPER.readValue(file, WalletFile.class);
215+
WalletFile wf = MAPPER.readValue(bytes, WalletFile.class);
207216
if (KeystoreCliUtils.isValidKeystoreFile(wf)
208217
&& targetAddress.equals(wf.getAddress())) {
209218
matches.add(file);

0 commit comments

Comments
 (0)