Skip to content

Commit 4697d52

Browse files
committed
ascanrules: Path Traversal add details for dir match Alerts & reduce FPs
- CHANGELOG > Added change note. - Message.properties > Added key/value pair supporting the new Alert details. - PathTraversalScanRule > Updated to include Other Info on Alerts when applicable, and pre-check the original message response to reduce false positives. - PathTraversalScanRuleUnitTest > Updated to assert Other Info or lack thereof where applicable, also assure appropriate skipping due to pre-conditions. Signed-off-by: kingthorin <kingthorin@users.noreply.github.com>
1 parent 3159ed2 commit 4697d52

File tree

4 files changed

+166
-39
lines changed

4 files changed

+166
-39
lines changed

addOns/ascanrules/CHANGELOG.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ All notable changes to this add-on will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
55

66
## Unreleased
7-
7+
### Changed
8+
- The Path Traversal scan rule now includes further details when directory matches are made and pre-checks the original message to reduce false positives (Issue 8379).
89

910
## [71] - 2025-03-04
1011
### Fixed

addOns/ascanrules/src/main/java/org/zaproxy/zap/extension/ascanrules/PathTraversalScanRule.java

+78-36
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ public class PathTraversalScanRule extends AbstractAppParamPlugin
8181
*/
8282
private static final ContentsMatcher WIN_PATTERN =
8383
new PatternContentsMatcher(Pattern.compile("\\[drivers\\]"));
84+
private static final String WIN_DIR_EVIDENCE = "Windows";
8485
private static final String[] WIN_LOCAL_FILE_TARGETS = {
8586
// Absolute Windows file retrieval (we suppose C:\\)
8687
"c:/Windows/system.ini",
@@ -130,6 +131,7 @@ public class PathTraversalScanRule extends AbstractAppParamPlugin
130131
// Dot used to match 'x' or '!' (used in AIX)
131132
private static final ContentsMatcher NIX_PATTERN =
132133
new PatternContentsMatcher(Pattern.compile("root:.:0:0"));
134+
private static final String NIX_DIR_EVIDENCE = "etc";
133135
private static final String[] NIX_LOCAL_FILE_TARGETS = {
134136
// Absolute file retrieval
135137
"/etc/passwd",
@@ -159,7 +161,8 @@ public class PathTraversalScanRule extends AbstractAppParamPlugin
159161
* Windows/Unix/Linux/etc. local directory targets and detection pattern
160162
*/
161163
private static final ContentsMatcher DIR_PATTERN = new DirNamesContentsMatcher();
162-
private static final String[] WIN_LOCAL_DIR_TARGETS = {
164+
/* Increased visibility purely for testing purposes */
165+
public static final String[] WIN_LOCAL_DIR_TARGETS = {
163166
"c:/",
164167
"c:\\",
165168
"..\\..\\..\\..\\..\\..\\..\\..\\..\\..\\..\\..\\..\\..\\..\\..\\",
@@ -176,7 +179,7 @@ public class PathTraversalScanRule extends AbstractAppParamPlugin
176179
"file:\\\\\\d:\\",
177180
"file:\\\\\\d:/"
178181
};
179-
private static final String[] NIX_LOCAL_DIR_TARGETS = {
182+
public static final String[] NIX_LOCAL_DIR_TARGETS = {
180183
"/",
181184
"../../../../../../../../../../../../../../../../",
182185
"/../../../../../../../../../../../../../../../../",
@@ -191,6 +194,8 @@ public class PathTraversalScanRule extends AbstractAppParamPlugin
191194
*/
192195
private static final String[] LOCAL_FILE_RELATIVE_PREFIXES = {"", "/", "\\"};
193196

197+
private static final List<String> DIR_EVIDENCE_LIST =
198+
List.of(NIX_DIR_EVIDENCE, WIN_DIR_EVIDENCE);
194199
/*
195200
* details of the vulnerability which we are attempting to find
196201
*/
@@ -341,7 +346,6 @@ public void scan(HttpMessage msg, String param, String value) {
341346

342347
// Check 2: Start detection for *NIX patterns
343348
if (inScope(Tech.Linux) || inScope(Tech.MacOS)) {
344-
345349
for (int h = 0; h < nixCount; h++) {
346350

347351
// Check if a there was a finding or the scan has been stopped
@@ -382,8 +386,11 @@ public void scan(HttpMessage msg, String param, String value) {
382386

383387
// Check 3: Detect if this page is a directory browsing component
384388
if (inScope(Tech.Linux) || inScope(Tech.MacOS)) {
385-
for (int h = 0; h < nixDirCount; h++) {
386-
389+
int h = 0;
390+
if (!isGoodCandidate(getBaseMsg(), Tech.Linux)) {
391+
h = nixDirCount;
392+
}
393+
for (; h < nixDirCount; h++) {
387394
// Check if a there was a finding or the scan has been stopped
388395
// if yes dispose resources and exit
389396
if (sendAndCheckPayload(param, NIX_LOCAL_DIR_TARGETS[h], DIR_PATTERN, 3)
@@ -395,7 +402,11 @@ public void scan(HttpMessage msg, String param, String value) {
395402
}
396403
}
397404
if (inScope(Tech.Windows)) {
398-
for (int h = 0; h < winDirCount; h++) {
405+
int h = 0;
406+
if (!isGoodCandidate(getBaseMsg(), Tech.Windows)) {
407+
h = winDirCount;
408+
}
409+
for (; h < winDirCount; h++) {
399410
if (sendAndCheckPayload(param, WIN_LOCAL_DIR_TARGETS[h], DIR_PATTERN, 3)
400411
|| isStop()) {
401412
// Dispose all resources
@@ -569,6 +580,19 @@ public void scan(HttpMessage msg, String param, String value) {
569580
}
570581
}
571582

583+
private static boolean isGoodCandidate(HttpMessage msg, Tech targetTech) {
584+
// It is a good candidate if it doesn't have evidence to start with
585+
if (targetTech == Tech.Windows) {
586+
return DirNamesContentsMatcher.matchWinDirectories(msg.getResponseBody().toString())
587+
== null;
588+
}
589+
if (targetTech == Tech.Linux) {
590+
return DirNamesContentsMatcher.matchNixDirectories(msg.getResponseBody().toString())
591+
== null;
592+
}
593+
return false;
594+
}
595+
572596
private boolean sendAndCheckPayload(
573597
String param, String newValue, ContentsMatcher contentsMatcher, int check)
574598
throws IOException {
@@ -658,12 +682,23 @@ private AlertBuilder createUnmatchedAlert(String param, String attack) {
658682

659683
private AlertBuilder createMatchedAlert(
660684
String param, String attack, String evidence, int check) {
661-
return newAlert()
662-
.setConfidence(Alert.CONFIDENCE_MEDIUM)
663-
.setParam(param)
664-
.setAttack(attack)
665-
.setEvidence(evidence)
666-
.setAlertRef(getId() + "-" + check);
685+
AlertBuilder builder =
686+
newAlert()
687+
.setConfidence(Alert.CONFIDENCE_MEDIUM)
688+
.setParam(param)
689+
.setAttack(attack)
690+
.setEvidence(evidence)
691+
.setAlertRef(getId() + "-" + check);
692+
if (DIR_EVIDENCE_LIST.contains(evidence)) {
693+
builder.setOtherInfo(
694+
Constant.messages.getString(
695+
MESSAGE_PREFIX + "info",
696+
evidence,
697+
evidence.equals(WIN_DIR_EVIDENCE)
698+
? DirNamesContentsMatcher.WIN_MATCHES
699+
: DirNamesContentsMatcher.NIX_MATCHES));
700+
}
701+
return builder;
667702
}
668703

669704
@Override
@@ -705,6 +740,26 @@ public String match(String contents) {
705740

706741
private static class DirNamesContentsMatcher implements ContentsMatcher {
707742

743+
private static final String NIX_MATCHES =
744+
String.join(", ", List.of("proc", NIX_DIR_EVIDENCE, "boot", "tmp", "home"));
745+
private static final String WIN_MATCHES =
746+
String.join(", ", List.of(WIN_DIR_EVIDENCE, "Program Files"));
747+
private static final Pattern PROC_PATT =
748+
Pattern.compile(
749+
"(?:^|\\W)proc(?:\\W|$)", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE);
750+
private static final Pattern ETC_PATT =
751+
Pattern.compile(
752+
"(?:^|\\W)etc(?:\\W|$)", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE);
753+
private static final Pattern BOOT_PATT =
754+
Pattern.compile(
755+
"(?:^|\\W)boot(?:\\W|$)", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE);
756+
private static final Pattern TMP_PATT =
757+
Pattern.compile(
758+
"(?:^|\\W)tmp(?:\\W|$)", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE);
759+
private static final Pattern HOME_PATT =
760+
Pattern.compile(
761+
"(?:^|\\W)home(?:\\W|$)", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE);
762+
708763
@Override
709764
public String match(String contents) {
710765
String result = matchNixDirectories(contents);
@@ -715,36 +770,23 @@ public String match(String contents) {
715770
}
716771

717772
private static String matchNixDirectories(String contents) {
718-
Pattern procPattern =
719-
Pattern.compile("(?:^|\\W)proc(?:\\W|$)", Pattern.CASE_INSENSITIVE);
720-
Pattern etcPattern = Pattern.compile("(?:^|\\W)etc(?:\\W|$)", Pattern.CASE_INSENSITIVE);
721-
Pattern bootPattern =
722-
Pattern.compile("(?:^|\\W)boot(?:\\W|$)", Pattern.CASE_INSENSITIVE);
723-
Pattern tmpPattern = Pattern.compile("(?:^|\\W)tmp(?:\\W|$)", Pattern.CASE_INSENSITIVE);
724-
Pattern homePattern =
725-
Pattern.compile("(?:^|\\W)home(?:\\W|$)", Pattern.CASE_INSENSITIVE);
726-
727-
Matcher procMatcher = procPattern.matcher(contents);
728-
Matcher etcMatcher = etcPattern.matcher(contents);
729-
Matcher bootMatcher = bootPattern.matcher(contents);
730-
Matcher tmpMatcher = tmpPattern.matcher(contents);
731-
Matcher homeMatcher = homePattern.matcher(contents);
732-
733-
if (procMatcher.find()
734-
&& etcMatcher.find()
735-
&& bootMatcher.find()
736-
&& tmpMatcher.find()
737-
&& homeMatcher.find()) {
738-
return "etc";
773+
if (PROC_PATT.matcher(contents).find()
774+
&& ETC_PATT.matcher(contents).find()
775+
&& BOOT_PATT.matcher(contents).find()
776+
&& TMP_PATT.matcher(contents).find()
777+
&& HOME_PATT.matcher(contents).find()) {
778+
return NIX_DIR_EVIDENCE;
739779
}
740780

741781
return null;
742782
}
743783

744784
private static String matchWinDirectories(String contents) {
745-
if (contents.contains("Windows")
746-
&& Pattern.compile("Program\\sFiles").matcher(contents).find()) {
747-
return "Windows";
785+
if (contents.contains(WIN_DIR_EVIDENCE)
786+
&& Pattern.compile("Program\\sFiles", Pattern.CASE_INSENSITIVE)
787+
.matcher(contents)
788+
.find()) {
789+
return WIN_DIR_EVIDENCE;
748790
}
749791

750792
return null;

addOns/ascanrules/src/main/resources/org/zaproxy/zap/extension/ascanrules/resources/Messages.properties

+1
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ ascanrules.parametertamper.desc = Parameter manipulation caused an error page or
114114
ascanrules.parametertamper.name = Parameter Tampering
115115
ascanrules.parametertamper.soln = Identify the cause of the error and fix it. Do not trust client side input and enforce a tight check in the server side. Besides, catch the exception properly. Use a generic 500 error page for internal server error.
116116

117+
ascanrules.pathtraversal.info = While the evidence field indicates {0}, the rule actually checked that the response contains matches for all of the following: {1}.
117118
ascanrules.pathtraversal.name = Path Traversal
118119

119120
ascanrules.payloader.desc = Provides support for custom payloads in scan rules.

addOns/ascanrules/src/test/java/org/zaproxy/zap/extension/ascanrules/PathTraversalScanRuleUnitTest.java

+85-2
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
import static fi.iki.elonen.NanoHTTPD.newFixedLengthResponse;
2323
import static org.hamcrest.MatcherAssert.assertThat;
24+
import static org.hamcrest.Matchers.emptyString;
2425
import static org.hamcrest.Matchers.equalTo;
2526
import static org.hamcrest.Matchers.greaterThan;
2627
import static org.hamcrest.Matchers.hasSize;
@@ -29,12 +30,17 @@
2930
import fi.iki.elonen.NanoHTTPD;
3031
import fi.iki.elonen.NanoHTTPD.IHTTPSession;
3132
import fi.iki.elonen.NanoHTTPD.Response;
33+
import java.util.Arrays;
3234
import java.util.List;
3335
import java.util.Map;
36+
import java.util.stream.Stream;
3437
import org.apache.commons.lang3.ArrayUtils;
3538
import org.junit.jupiter.api.Test;
3639
import org.junit.jupiter.params.ParameterizedTest;
40+
import org.junit.jupiter.params.provider.Arguments;
41+
import org.junit.jupiter.params.provider.CsvSource;
3742
import org.junit.jupiter.params.provider.EnumSource;
43+
import org.junit.jupiter.params.provider.MethodSource;
3844
import org.junit.jupiter.params.provider.ValueSource;
3945
import org.parosproxy.paros.core.scanner.Alert;
4046
import org.parosproxy.paros.core.scanner.Plugin;
@@ -169,6 +175,13 @@ void shouldAlertIfAttackResponseListsWindowsDirectories() throws Exception {
169175
assertThat(alertsRaised.get(0).getAttack(), is(equalTo("c:/")));
170176
assertThat(alertsRaised.get(0).getRisk(), is(equalTo(Alert.RISK_HIGH)));
171177
assertThat(alertsRaised.get(0).getConfidence(), is(equalTo(Alert.CONFIDENCE_MEDIUM)));
178+
assertThat(
179+
alertsRaised.get(0).getOtherInfo(),
180+
is(
181+
equalTo(
182+
"While the evidence field indicates Windows, the rule actually "
183+
+ "checked that the response contains matches for all of the "
184+
+ "following: Windows, Program Files.")));
172185
assertThat(alertsRaised.get(0).getAlertRef(), is(equalTo("6-3")));
173186
}
174187

@@ -180,13 +193,20 @@ void shouldAlertIfAttackResponseListsLinuxDirectories() throws Exception {
180193
// When
181194
rule.scan();
182195
// Then
183-
assertThat(httpMessagesSent, hasSize(greaterThan(1)));
196+
assertThat(httpMessagesSent, hasSize(equalTo(7)));
184197
assertThat(alertsRaised, hasSize(1));
185198
assertThat(alertsRaised.get(0).getEvidence(), is(equalTo("etc")));
186199
assertThat(alertsRaised.get(0).getParam(), is(equalTo("p")));
187200
assertThat(alertsRaised.get(0).getAttack(), is(equalTo("/")));
188201
assertThat(alertsRaised.get(0).getRisk(), is(equalTo(Alert.RISK_HIGH)));
189202
assertThat(alertsRaised.get(0).getConfidence(), is(equalTo(Alert.CONFIDENCE_MEDIUM)));
203+
assertThat(
204+
alertsRaised.get(0).getOtherInfo(),
205+
is(
206+
equalTo(
207+
"While the evidence field indicates etc, the rule actually "
208+
+ "checked that the response contains matches for all of the "
209+
+ "following: proc, etc, boot, tmp, home.")));
190210
assertThat(alertsRaised.get(0).getAlertRef(), is(equalTo("6-3")));
191211
}
192212

@@ -198,13 +218,20 @@ void shouldAlertIfAttackResponseListsLinuxDirectoriesInPlainText() throws Except
198218
// When
199219
rule.scan();
200220
// Then
201-
assertThat(httpMessagesSent, hasSize(greaterThan(1)));
221+
assertThat(httpMessagesSent, hasSize(equalTo(7)));
202222
assertThat(alertsRaised, hasSize(1));
203223
assertThat(alertsRaised.get(0).getEvidence(), is(equalTo("etc")));
204224
assertThat(alertsRaised.get(0).getParam(), is(equalTo("p")));
205225
assertThat(alertsRaised.get(0).getAttack(), is(equalTo("/")));
206226
assertThat(alertsRaised.get(0).getRisk(), is(equalTo(Alert.RISK_HIGH)));
207227
assertThat(alertsRaised.get(0).getConfidence(), is(equalTo(Alert.CONFIDENCE_MEDIUM)));
228+
assertThat(
229+
alertsRaised.get(0).getOtherInfo(),
230+
is(
231+
equalTo(
232+
"While the evidence field indicates etc, the rule actually "
233+
+ "checked that the response contains matches for all of the "
234+
+ "following: proc, etc, boot, tmp, home.")));
208235
assertThat(alertsRaised.get(0).getAlertRef(), is(equalTo("6-3")));
209236
}
210237

@@ -278,6 +305,7 @@ void shouldRaiseAlertIfResponseHasPasswdFileContentAndPayloadIsNullByteBased()
278305
// Then
279306
assertThat(alertsRaised, hasSize(1));
280307
assertThat(alertsRaised.get(0).getAlertRef(), is(equalTo("6-2")));
308+
assertThat(alertsRaised.get(0).getOtherInfo(), is(emptyString()));
281309
}
282310

283311
@Test
@@ -294,6 +322,7 @@ void shouldRaiseAlertIfResponseHasSystemINIFileContentAndPayloadIsNullByteBased(
294322
// Then
295323
assertThat(alertsRaised, hasSize(1));
296324
assertThat(alertsRaised.get(0).getAlertRef(), is(equalTo("6-1")));
325+
assertThat(alertsRaised.get(0).getOtherInfo(), is(emptyString()));
297326
}
298327

299328
@ParameterizedTest
@@ -314,6 +343,7 @@ void shouldAlertOnCheckFiveBelowHighThresholdUnderValidConditions(AlertThreshold
314343
assertThat(alertsRaised, hasSize(1));
315344
assertThat(alertsRaised.get(0).getConfidence(), is(equalTo(Alert.CONFIDENCE_LOW)));
316345
assertThat(alertsRaised.get(0).getAlertRef(), is(equalTo("6-5")));
346+
assertThat(alertsRaised.get(0).getOtherInfo(), is(emptyString()));
317347
}
318348

319349
@Test
@@ -362,6 +392,59 @@ void shouldNotAlertOnCheckFiveAtLowThresholdUnderInvalidConditions(String errorT
362392
assertThat(alertsRaised, hasSize(0));
363393
}
364394

395+
@ParameterizedTest
396+
@CsvSource({
397+
// Windows will always happen before ignoring Linux, if only Linux pre-check matches
398+
"foo etc root tmp bin boot dev home mnt opt proc bar, 11",
399+
"foo Program Files Users Windows bar, 11",
400+
"foo etc root tmp bin boot dev home mnt opt proc Program Files Users Windows bar, 11"
401+
})
402+
void shouldSkipDirChecksIfResponseHasDirEvidenceToStartWith(String content, int expected)
403+
throws Exception {
404+
// Given
405+
HttpMessage msg = getHttpMessage("/?p=v");
406+
msg.setResponseBody(content);
407+
rule.init(msg, parent);
408+
// When
409+
rule.scan();
410+
// Then
411+
assertThat(httpMessagesSent, hasSize(equalTo(expected)));
412+
}
413+
414+
private static Stream<Arguments> handlersProvider() {
415+
return Stream.of(
416+
Arguments.of(
417+
new ListLinuxDirsOnAttack("/foo", "p", "/"),
418+
Arrays.asList(PathTraversalScanRule.NIX_LOCAL_DIR_TARGETS)),
419+
Arguments.of(
420+
new ListWinDirsOnAttack("/bar", "p", "c:/"),
421+
Arrays.asList(PathTraversalScanRule.WIN_LOCAL_DIR_TARGETS)));
422+
}
423+
424+
@ParameterizedTest
425+
@MethodSource("handlersProvider")
426+
void shouldNotSkipIfGoodCandidateForOnlyOneTech(
427+
NanoServerHandler handler, List<String> skippedPayloads) throws Exception {
428+
// Given
429+
nano.addHandler(handler);
430+
HttpMessage msg = getHttpMessage("/?p=v");
431+
rule.init(msg, parent);
432+
// When
433+
rule.scan();
434+
// Then
435+
System.out.println("Expected Skipped Payloads: " + skippedPayloads);
436+
for (HttpMessage m : httpMessagesSent) {
437+
String paramVal = m.getUrlParams().first().getValue();
438+
for (String payload : skippedPayloads) {
439+
System.out.println("Payload: " + payload);
440+
System.out.println("Param Val: " + m.getUrlParams().first().getValue());
441+
assertThat(paramVal.equals(payload), is(equalTo(false)));
442+
}
443+
}
444+
assertThat(alertsRaised, hasSize(equalTo(1)));
445+
System.out.println("Attack: " + alertsRaised.get(0).getAttack());
446+
}
447+
365448
private abstract static class ListDirsOnAttack extends NanoServerHandler {
366449

367450
private final String param;

0 commit comments

Comments
 (0)