Skip to content

Commit 2273aad

Browse files
author
Alvaro Muñoz
committed
Improve Cache Poisoning query
The untrusted files path is compared with the path written to the cache to check if the cache can really be poisoned
1 parent 34b48d5 commit 2273aad

File tree

1 file changed

+45
-7
lines changed

1 file changed

+45
-7
lines changed

ql/src/Security/CWE-349/CachePoisoning.ql

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,47 @@ import codeql.actions.security.CachePoisoningQuery
1818
import codeql.actions.security.PoisonableSteps
1919
import codeql.actions.security.ControlChecks
2020

21+
/**
22+
* Holds if the path cache_path is a subpath of the path untrusted_path.
23+
*/
24+
bindingset[cache_path, untrusted_path]
25+
predicate controlledCachePath(string cache_path, string untrusted_path) {
26+
exists(string normalized_cache_path, string normalized_untrusted_path |
27+
(
28+
cache_path.regexpMatch("^[a-zA-Z0-9_-].*") and
29+
normalized_cache_path = "./" + cache_path.regexpReplaceAll("/$", "")
30+
or
31+
normalized_cache_path = cache_path.regexpReplaceAll("/$", "")
32+
) and
33+
(
34+
untrusted_path.regexpMatch("^[a-zA-Z0-9_-].*") and
35+
normalized_untrusted_path = "./" + untrusted_path.regexpReplaceAll("/$", "")
36+
or
37+
normalized_untrusted_path = untrusted_path.regexpReplaceAll("/$", "")
38+
) and
39+
normalized_cache_path.substring(0, normalized_untrusted_path.length()) =
40+
normalized_untrusted_path
41+
)
42+
}
43+
2144
query predicate edges(Step a, Step b) { a.getNextStep() = b }
2245

23-
from LocalJob j, Event e, Step artifact, Step s
46+
from LocalJob j, Event e, Step source, Step s, string message, string path
2447
where
2548
(
26-
artifact instanceof PRHeadCheckoutStep or
27-
artifact instanceof UntrustedArtifactDownloadStep
49+
source instanceof PRHeadCheckoutStep and
50+
message = "due to privilege checkout of untrusted code." and
51+
path = source.(PRHeadCheckoutStep).getPath()
52+
or
53+
source instanceof UntrustedArtifactDownloadStep and
54+
message = "due to downloading an untrusted artifact." and
55+
path = source.(UntrustedArtifactDownloadStep).getPath()
2856
) and
2957
j.getATriggerEvent() = e and
3058
// job can be triggered by an external user
3159
e.isExternallyTriggerable() and
3260
// the checkout is not controlled by an access check
33-
not exists(ControlCheck check | check.protects(artifact, j.getATriggerEvent())) and
61+
not exists(ControlCheck check | check.protects(source, j.getATriggerEvent())) and
3462
(
3563
// the workflow runs in the context of the default branch
3664
runsOnDefaultBranch(e)
@@ -43,19 +71,29 @@ where
4371
)
4472
) and
4573
// the job checkouts untrusted code from a pull request
46-
j.getAStep() = artifact and
74+
j.getAStep() = source and
4775
(
4876
// the job writes to the cache
4977
// (No need to follow the checkout step as the cache writing is normally done after the job completes)
5078
j.getAStep() = s and
5179
s instanceof CacheWritingStep and
80+
(
81+
// we dont know what code can be controlled by the attacker
82+
path = "?"
83+
or
84+
// we dont know what files are being cached
85+
s.(CacheWritingStep).getPath() = "?"
86+
or
87+
// the cache writing step reads from the path the attacker can control
88+
not path = "?" and controlledCachePath(s.(CacheWritingStep).getPath(), path)
89+
) and
5290
not s instanceof PoisonableStep
5391
or
5492
// the job executes checked-out code
5593
// (The cache specific token can be leaked even for non-privileged workflows)
56-
artifact.getAFollowingStep() = s and
94+
source.getAFollowingStep() = s and
5795
s instanceof PoisonableStep and
5896
// excluding privileged workflows since they can be exploited in easier circumstances
5997
not j.isPrivileged()
6098
)
61-
select s, artifact, s, "Potential cache poisoning in the context of the default branch"
99+
select s, source, s, "Potential cache poisoning in the context of the default branch" + message

0 commit comments

Comments
 (0)