Skip to content

Commit 8d3f4e9

Browse files
committed
First iteration detecting path traversal attacks
1 parent fcb053b commit 8d3f4e9

File tree

9 files changed

+357
-0
lines changed

9 files changed

+357
-0
lines changed

lib/aikido/zen/attack.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,39 @@ def exception(*)
3939
end
4040

4141
module Attacks
42+
class PathTraversalAttack < Attack
43+
attr_reader :input
44+
attr_reader :filepath
45+
46+
def initialize(input:, filepath:, **opts)
47+
super(**opts)
48+
@input = input
49+
@filepath = filepath
50+
end
51+
52+
def log_message
53+
format(
54+
"Path Traversal: Malicious user input «%s» detected while calling method %s",
55+
@input, @operation
56+
)
57+
end
58+
59+
def as_json
60+
{
61+
kind: "path_traversal",
62+
blocked: blocked?,
63+
metadata: {
64+
expanded_filepath: filepath
65+
},
66+
operation: @operation
67+
}.merge(@input.as_json)
68+
end
69+
70+
def exception(*)
71+
PathTraversalError.new(self)
72+
end
73+
end
74+
4275
class SQLInjectionAttack < Attack
4376
attr_reader :query
4477
attr_reader :input

lib/aikido/zen/errors.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ class SSRFDetectedError < UnderAttackError
7575
def_delegators :@attack, :request, :input
7676
end
7777

78+
class PathTraversalError < UnderAttackError
79+
extend Forwardable
80+
def_delegators :@attack, :input
81+
end
82+
7883
# Raised when there's any problem communicating (or loading) libzen.
7984
class InternalsError < ZenError
8085
# @param attempt [String] description of what we were trying to do.

lib/aikido/zen/scanners.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
require_relative "scanners/sql_injection_scanner"
44
require_relative "scanners/stored_ssrf_scanner"
55
require_relative "scanners/ssrf_scanner"
6+
require_relative "scanners/path_traversal_scanner"
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# frozen_string_literal: true
2+
3+
module Aikido::Zen
4+
module Scanners
5+
module PathTraversal
6+
DANGEROUS_PATH_PARTS = ["../", "..\\"]
7+
LINUX_ROOT_FOLDERS = [
8+
"/bin/",
9+
"/boot/",
10+
"/dev/",
11+
"/etc/",
12+
"/home/",
13+
"/init/",
14+
"/lib/",
15+
"/media/",
16+
"/mnt/",
17+
"/opt/",
18+
"/proc/",
19+
"/root/",
20+
"/run/",
21+
"/sbin/",
22+
"/srv/",
23+
"/sys/",
24+
"/tmp/",
25+
"/usr/",
26+
"/var/"
27+
]
28+
29+
DANGEROUS_PATH_STARTS = LINUX_ROOT_FOLDERS + ["c:/", "c:\\"]
30+
31+
module Helpers
32+
def self.contains_unsafe_path_parts(filepath)
33+
DANGEROUS_PATH_PARTS.each do |dangerous_part|
34+
return true if filepath.include?(dangerous_part)
35+
end
36+
37+
false
38+
end
39+
40+
def self.starts_with_unsafe_path(filepath, user_input)
41+
# Check if path is relative (not absolute or drive letter path)
42+
# Required because `expand_path` will build absolute paths from relative paths
43+
return false if Pathname.new(filepath).relative? || Pathname.new(user_input).relative?
44+
45+
normalized_path = File.expand_path(filepath).downcase
46+
normalized_user_input = File.expand_path(user_input).downcase
47+
48+
DANGEROUS_PATH_STARTS.each do |dangerous_start|
49+
if normalized_path.start_with?(dangerous_start) && normalized_path.start_with?(normalized_user_input)
50+
# If the user input is the same as the dangerous start, we don't want to flag it
51+
# to prevent false positives.
52+
# e.g., if user input is /etc/ and the path is /etc/passwd, we don't want to flag it,
53+
# as long as the user input does not contain a subdirectory or filename
54+
if user_input == dangerous_start || user_input == dangerous_start.chomp("/")
55+
return false
56+
end
57+
return true
58+
end
59+
end
60+
false
61+
end
62+
end
63+
end
64+
end
65+
end
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "path_traversal/helpers"
4+
5+
module Aikido::Zen
6+
module Scanners
7+
class PathTraversalScanner
8+
# Checks if the user introduced input is trying to access other path using
9+
# Path Traversal kind of attacks.
10+
#
11+
# @param filepath [String] the expanded path that is tried to be read
12+
# @param context [Aikido::Zen::Context]
13+
# @param sink [Aikido::Zen::Sink] the Sink that is running the scan.
14+
# @param operation [Symbol, String] name of the method being scanned.
15+
#
16+
# @return [Aikido::Zen::Attacks::PathTraversalAttack, nil] an Attack if any
17+
# user input is detected to be attempting a Path Traversal Attack, or +nil+ if not.
18+
def self.call(filepath:, sink:, context:, operation:)
19+
return unless context
20+
21+
context.payloads.each do |payload|
22+
next unless new(filepath, payload.value).attack?
23+
24+
return Attacks::PathTraversalAttack.new(
25+
sink: sink,
26+
input: payload,
27+
filepath: filepath,
28+
context: context,
29+
operation: "#{sink.operation}.#{operation}"
30+
)
31+
end
32+
33+
nil
34+
end
35+
36+
def initialize(filepath, input)
37+
@filepath = filepath.downcase
38+
@input = input.downcase
39+
end
40+
41+
def attack?
42+
# Single character are ignored because they don't pose a big threat
43+
return false if @input.length <= 1
44+
45+
# We ignore cases where the user input is longer than the file path.
46+
# Because the user input can't be part of the file path.
47+
return false if @input.length > @filepath.length
48+
49+
# We ignore cases where the user input is not part of the file path.
50+
return false unless @filepath.include?(@input)
51+
52+
if PathTraversal::Helpers.contains_unsafe_path_parts(@filepath) && PathTraversal::Helpers.contains_unsafe_path_parts(@input)
53+
return true
54+
end
55+
56+
# Check for absolute path traversal
57+
PathTraversal::Helpers.starts_with_unsafe_path(@filepath, @input)
58+
end
59+
end
60+
end
61+
end

lib/aikido/zen/sinks.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
require_relative "sinks/socket"
66

77
require_relative "sinks/action_controller" if defined?(::ActionController)
8+
require_relative "sinks/file" if defined?(::File)
89
require_relative "sinks/resolv" if defined?(::Resolv)
910
require_relative "sinks/net_http" if defined?(::Net::HTTP)
1011
require_relative "sinks/http" if defined?(::HTTP)

lib/aikido/zen/sinks/file.rb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# frozen_string_literal: true
2+
3+
module Aikido::Zen
4+
# Hooked only on `read` & `write` methods of `File`. We could extends to other methods like `open`
5+
# but that's outside of the challenge scope.
6+
module Sinks
7+
module File
8+
SINK = Sinks.add("File", scanners: [
9+
Aikido::Zen::Scanners::PathTraversalScanner
10+
])
11+
12+
module Extensions
13+
def self.scan_path(filepath, operation)
14+
Aikido::Zen.config.logger.debug "Sending to scan: #{filepath}"
15+
16+
SINK.scan(
17+
filepath: filepath,
18+
operation: operation
19+
)
20+
end
21+
22+
def read(filename, *)
23+
Extensions.scan_path(filename, "read")
24+
25+
super
26+
end
27+
28+
def write(filename, *, **)
29+
Extensions.scan_path(filename, "write")
30+
31+
super
32+
end
33+
end
34+
end
35+
end
36+
end
37+
38+
::File.singleton_class.prepend(Aikido::Zen::Sinks::File::Extensions)
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
class Aikido::Zen::Scanners::PathTraversalScannerTest < ActiveSupport::TestCase
6+
def assert_attack(filepath, input = filepath, reason = "#{input} was not blocked")
7+
assert scan(filepath, input), reason
8+
end
9+
10+
def refute_attack(filepath, input = filepath, reason = "#{input} was blocked")
11+
refute scan(filepath, input), reason
12+
end
13+
14+
def scan(filepath, input = query)
15+
Aikido::Zen::Scanners::PathTraversalScanner.new(filepath, input).attack?
16+
end
17+
18+
test "no path traversal" do
19+
refute_attack "/some-directory/sub-folder/file.txt", "/sub-folder/file.txt"
20+
end
21+
22+
test "ignores input with length <= 1" do
23+
refute_attack ""
24+
refute_attack "a"
25+
refute_attack "abcd", ""
26+
refute_attack "abcd", "a"
27+
end
28+
29+
test "ignores in case the input is longer than the filepath" do
30+
refute_attack "1", "12"
31+
refute_attack "base-string", "base-string-plus-words"
32+
end
33+
34+
test "ignores in case the input not contained by filepath" do
35+
refute_attack "1", "a"
36+
refute_attack "base-string", "base-string".reverse
37+
end
38+
39+
test "same as user input" do
40+
refute_attack "file.txt", "file.txt"
41+
end
42+
43+
test "with directory before" do
44+
refute_attack "directory/file.txt", "file.txt"
45+
refute_attack "directory/file.txt", "directory/file.txt"
46+
end
47+
48+
test "it flags bad inputs" do
49+
# inputs with ../
50+
assert_attack "../file.txt", "../"
51+
assert_attack "../file.txt", "../file.txt"
52+
assert_attack "../../file.txt", "../../"
53+
assert_attack "../../file.txt", "../../file.txt"
54+
55+
# inputs with ..\\
56+
assert_attack "..\\file.txt", "..\\"
57+
assert_attack "..\\file.txt", "..\\file.txt"
58+
assert_attack "..\\..\\file.txt", "..\\..\\"
59+
assert_attack "..\\..\\file.txt", "..\\..\\file.txt"
60+
61+
# inputs with ./../
62+
assert_attack "./../file.txt", "./../"
63+
assert_attack "./../file.txt", "./../file.txt"
64+
assert_attack "./../../file.txt", "./../../"
65+
assert_attack "./../../file.txt", "./../../file.txt"
66+
end
67+
68+
test "linux paths" do
69+
refute_attack "/etc/passwd", "/etc/"
70+
assert_attack "/etc/passwd", "/etc/passwd"
71+
assert_attack "/etc/../etc/passwd", "/etc/../etc/passwd"
72+
assert_attack "/home/user/file.txt", "/home/user"
73+
end
74+
75+
test "possible bypasses" do
76+
assert_attack "/./etc/passwd", "/./etc/passwd"
77+
assert_attack "/./././root/file.txt", "/./././root/"
78+
assert_attack "/./././root/file.txt", "/./././root/file.txt"
79+
end
80+
81+
test "does not detect if user input path contains no filename or subfolder" do
82+
refute_attack "/etc/app/test.txt", "/etc/"
83+
refute_attack "/etc/app/", "/etc/"
84+
refute_attack "/etc/app/", "/etc"
85+
refute_attack "/etc/", "/etc/"
86+
refute_attack "/etc", "/etc"
87+
refute_attack "/var/a", "/var/"
88+
refute_attack "/var/a", "/var/b"
89+
refute_attack "/var/a", "/var/b/test.txt"
90+
end
91+
92+
test "it does dected if user input path contains a filename or subfolder" do
93+
assert_attack "/etc/app/file.txt", "/etc/app"
94+
assert_attack "/etc/app/file.txt", "/etc/app/file.txt"
95+
assert_attack "/var/backups/file.txt", "/var/backups"
96+
assert_attack "/var/backups/file.txt", "/var/backups/file.txt"
97+
assert_attack "/var/a", "/var/a"
98+
assert_attack "/var/a/b", "/var/a"
99+
assert_attack "/var/a/b/test.txt", "/var/a"
100+
end
101+
end

test/aikido/zen/sinks/file_test.rb

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
class Aikido::Zen::Sinks::FileTest < ActiveSupport::TestCase
6+
include StubsCurrentContext
7+
include SinkAttackHelpers
8+
9+
test "scanning does not interfere with File.read normally" do
10+
tmp_file = Tempfile.new("path-traversal-sink-read")
11+
12+
begin
13+
tmp_file.write "some content"
14+
tmp_file.close
15+
16+
assert_equal File.read(tmp_file.path), "some content"
17+
ensure
18+
tmp_file.unlink
19+
end
20+
end
21+
22+
test "scanning does not interfere with File.write normally" do
23+
::Dir::Tmpname.create("path-traversal-sink-write", Dir.tmpdir) do |path|
24+
File.write path, "path-traversal-sink-write"
25+
26+
assert_equal File.read(path), "path-traversal-sink-write"
27+
File.unlink path
28+
end
29+
end
30+
31+
test "does not fail when the context is null" do
32+
refute_attack do
33+
# We expect the next `File.read` will fail *because& the file does not exist,
34+
# but no because it is Path Traversal Attack
35+
assert_raise Errno::ENOENT do
36+
File.read('../this-is-an-attack')
37+
end
38+
end
39+
end
40+
41+
test "scanning detects Path Traversal Attacks" do
42+
set_context_from_request_to "/?filename=../this-is-an-attack"
43+
44+
error = assert_attack Aikido::Zen::Attacks::PathTraversalAttack do
45+
File.read('../this-is-an-attack')
46+
end
47+
48+
assert_equal \
49+
error.message,
50+
"Path Traversal: Malicious user input «../this-is-an-attack» detected while calling method File.read"
51+
end
52+
end

0 commit comments

Comments
 (0)