Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 1 addition & 14 deletions .idea/encodings.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,37 @@ mvn clean package -pl agent -am

The shaded JAR is at `agent/target/jcapslock-agent-1.0-SNAPSHOT.jar`.

## DEFAULT Blocking Mode

Use `DEFAULT` in the blocked list to block all capabilities NOT in the snapshot for a package:

```yaml
policies:
- package: com.example.myapp
blocked:
- DEFAULT # blocks all capabilities not in snapshot.json
```

The agent loads `.capslock/snapshot.json` (relative to policy file) and allows only
capabilities that exist in the snapshot for that package. Any capability usage not
in the snapshot is blocked.

## Limitations

> **Proof of Concept**: The enforcement mode is experimental and can be evaded.
> It should be used as one layer in a defense-in-depth strategy, not as a
> complete security solution.

Known evasion vectors:
- **Async/threaded code**: Stack inspection won't show original caller after handoff to thread pool
- **Reflection**: Dynamically invoked methods bypass static analysis
- **Native code**: JNI and `sun.misc.Unsafe` can perform any operation
- **External processes**: Code executed via `Runtime.exec` is not monitored
- **Class loading tricks**: Custom classloaders can load uninstrumented code

The static analysis also has inherent limitations:
- Reflection targets cannot be determined statically and lead to overapproximations

## Capabilities

Capabilities are loaded from `java-interesting.cm` in the core module.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package com.github.serj.jcapslock.agent;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import org.objectweb.asm.Type;

import java.io.File;
import java.lang.instrument.Instrumentation;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
Expand Down Expand Up @@ -73,6 +75,24 @@ public static Instrumentation getInstrumentation() {
return instrumentation;
}

/**
* All enforceable capabilities (excludes UNSPECIFIED, SAFE, UNANALYZED).
*/
private static final Set<String> ALL_CAPABILITIES = new HashSet<>(Arrays.asList(
"CAPABILITY_FILES",
"CAPABILITY_NETWORK",
"CAPABILITY_RUNTIME",
"CAPABILITY_READ_SYSTEM_STATE",
"CAPABILITY_MODIFY_SYSTEM_STATE",
"CAPABILITY_OPERATING_SYSTEM",
"CAPABILITY_SYSTEM_CALLS",
"CAPABILITY_ARBITRARY_EXECUTION",
"CAPABILITY_CGO",
"CAPABILITY_UNSAFE_POINTER",
"CAPABILITY_REFLECT",
"CAPABILITY_EXEC"
));

private static void loadPolicy(String policyPath) {
File policyFile = new File(policyPath);
if (!policyFile.exists()) {
Expand All @@ -86,14 +106,42 @@ private static void loadPolicy(String policyPath) {
ObjectMapper mapper = new ObjectMapper(new YAMLFactory());
PolicyFile policy = mapper.readValue(policyFile, PolicyFile.class);

// Check if any policy uses DEFAULT - if so, load snapshot
Map<String, Set<String>> snapshotCapabilities = null;
boolean hasDefault = policy.policies != null && policy.policies.stream()
.anyMatch(e -> e.blocked != null && e.blocked.contains("DEFAULT"));

if (hasDefault) {
snapshotCapabilities = loadSnapshotCapabilities(policyFile.getParentFile());
}

Map<String, Set<String>> capabilityToPackages = new HashMap<>();
if (policy.policies != null) {
for (PolicyEntry entry : policy.policies) {
if (entry.blocked != null) {
for (String capability : entry.blocked) {
capabilityToPackages
.computeIfAbsent(capability, k -> new HashSet<>())
.add(entry.pkg);
List<String> effectiveBlocked = entry.blocked;

// Expand DEFAULT to all capabilities not in snapshot for this package
if (entry.blocked.contains("DEFAULT")) {
Set<String> allowedFromSnapshot = snapshotCapabilities != null
? snapshotCapabilities.getOrDefault(entry.pkg, Set.of())
: Set.of();

Set<String> toBlock = new HashSet<>(ALL_CAPABILITIES);
toBlock.removeAll(allowedFromSnapshot);
effectiveBlocked = List.copyOf(toBlock);

Log.info("DEFAULT expansion for " + entry.pkg +
": allowed from snapshot=" + allowedFromSnapshot +
", blocking=" + toBlock);
}

for (String capability : effectiveBlocked) {
if (!"DEFAULT".equals(capability)) {
capabilityToPackages
.computeIfAbsent(capability, k -> new HashSet<>())
.add(entry.pkg);
}
}
}
}
Expand All @@ -110,6 +158,42 @@ private static void loadPolicy(String policyPath) {
}
}

/**
* Loads capabilities from snapshot.json in the given directory.
* Returns a map from package name to set of capabilities.
*/
private static Map<String, Set<String>> loadSnapshotCapabilities(File directory) {
File snapshotFile = new File(directory, "snapshot.json");
if (!snapshotFile.exists()) {
Log.warn("Snapshot file not found: " + snapshotFile + " (DEFAULT blocking may not work correctly)");
return Map.of();
}

try {
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(snapshotFile);
JsonNode capabilityInfoList = root.get("capability_info");

Map<String, Set<String>> result = new HashMap<>();
if (capabilityInfoList != null && capabilityInfoList.isArray()) {
for (JsonNode info : capabilityInfoList) {
String packageName = info.has("package_name") ? info.get("package_name").asText() : null;
String capability = info.has("capability") ? info.get("capability").asText() : null;

if (packageName != null && capability != null) {
result.computeIfAbsent(packageName, k -> new HashSet<>()).add(capability);
}
}
}

Log.info("Loaded snapshot with " + result.size() + " packages");
return result;
} catch (Exception e) {
Log.error("Failed to load snapshot: " + e.getMessage());
return Map.of();
}
}

public static class PolicyFile {
public List<PolicyEntry> policies;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,37 @@ public class PolicyChecker {

public static final String PROP_PREFIX = "capslock.block.";

/**
* Reentrancy guard to prevent infinite recursion when instrumented methods
* (like System.getProperty, Thread.getStackTrace) are called from within check().
* NOTE: Cannot use ThreadLocal.withInitial(() -> false) because lambda creation
* triggers instrumented methods before IN_CHECK is initialized.
*/
private static final ThreadLocal<Boolean> IN_CHECK = new ThreadLocal<Boolean>() {
@Override
protected Boolean initialValue() {
return Boolean.FALSE;
}
};

/**
* Check if capability is allowed for the current call stack.
* Throws SecurityException if blocked.
*/
public static void check(String capability) {
// Reentrancy guard: if we're already in a check, skip to prevent infinite recursion
if (IN_CHECK.get()) {
return;
}
IN_CHECK.set(true);
try {
doCheck(capability);
} finally {
IN_CHECK.set(false);
}
}

private static void doCheck(String capability) {
String blocked = System.getProperty(PROP_PREFIX + capability);
String[] blockedPackages = (blocked != null) ? blocked.split(",") : null;

Expand All @@ -31,21 +57,24 @@ public static void check(String capability) {
RuntimeCallGraph.recordPath(capability, appFrames);
}

logFrames(capability, appFrames);

if (blockedPackages != null) {
String violator = findViolator(appFrames, blockedPackages);
if (violator != null) {
logFrames(capability, appFrames);
throw new SecurityException("[CAPSLOCK] " + capability + " blocked for " + violator);
}
}
}

private static final String POLICY_CHECKER_CLASS = PolicyChecker.class.getName();

private static List<StackTraceElement> collectAppFrames() {
StackTraceElement[] stack = Thread.currentThread().getStackTrace();
List<StackTraceElement> appFrames = new ArrayList<>();
for (int i = 2; i < stack.length; i++) {
if (!JavaUtils.isJdkPackage(stack[i].getClassName())) {
String className = stack[i].getClassName();
// Filter out JDK packages and PolicyChecker itself (to avoid self-reference in logs)
if (!JavaUtils.isJdkPackage(className) && !className.equals(POLICY_CHECKER_CLASS)) {
appFrames.add(stack[i]);
}
}
Expand Down
Loading