From 2934a6ef4ac9f531f85cf0691f32fcfbd97dfcf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Wed, 24 Jun 2026 11:45:48 +0200 Subject: [PATCH 1/2] Instrument more functions accepting paths --- lib/aikido/zen/sinks.rb | 2 + lib/aikido/zen/sinks/file.rb | 4 + lib/aikido/zen/sinks/io.rb | 50 ++++++++ lib/aikido/zen/sinks/pathname.rb | 32 +++++ test/aikido/zen/sinks/file_test.rb | 15 +++ test/aikido/zen/sinks/io_test.rb | 170 +++++++++++++++++++++++++ test/aikido/zen/sinks/pathname_test.rb | 75 +++++++++++ 7 files changed, 348 insertions(+) create mode 100644 lib/aikido/zen/sinks/io.rb create mode 100644 lib/aikido/zen/sinks/pathname.rb create mode 100644 test/aikido/zen/sinks/io_test.rb create mode 100644 test/aikido/zen/sinks/pathname_test.rb diff --git a/lib/aikido/zen/sinks.rb b/lib/aikido/zen/sinks.rb index b0b88b68..d0a7e27f 100644 --- a/lib/aikido/zen/sinks.rb +++ b/lib/aikido/zen/sinks.rb @@ -12,6 +12,8 @@ require_relative "sinks/kernel" require_relative "sinks/file" +require_relative "sinks/io" +require_relative "sinks/pathname" require_relative "sinks/socket" require_relative "sinks/resolv" diff --git a/lib/aikido/zen/sinks/file.rb b/lib/aikido/zen/sinks/file.rb index fbbd3fa6..92057def 100644 --- a/lib/aikido/zen/sinks/file.rb +++ b/lib/aikido/zen/sinks/file.rb @@ -129,6 +129,10 @@ def join(*args, **kwargs, &blk) Helpers.scan(file_name, "expand_path") end + sink_before :absolute_path do |file_name| + Helpers.scan(file_name, "absolute_path") + end + sink_before :realpath do |file_name| Helpers.scan(file_name, "realpath") end diff --git a/lib/aikido/zen/sinks/io.rb b/lib/aikido/zen/sinks/io.rb new file mode 100644 index 00000000..bd86ef58 --- /dev/null +++ b/lib/aikido/zen/sinks/io.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Aikido::Zen + module Sinks + module IO + SINK = Sinks.add("IO", scanners: [Scanners::PathTraversalScanner]) + + module Helpers + def self.scan(filepath, operation) + SINK.scan( + filepath: filepath, + operation: operation + ) + end + end + + def self.load_sinks! + ::IO.singleton_class.class_eval do + extend Sinks::DSL + + sink_before :read do |path, *| + Helpers.scan(path, "read") + end + + sink_before :write do |path, *| + Helpers.scan(path, "write") + end + + sink_before :foreach do |path, *| + Helpers.scan(path, "foreach") + end + + sink_before :readlines do |path, *| + Helpers.scan(path, "readlines") + end + + sink_before :binread do |path, *| + Helpers.scan(path, "binread") + end + + sink_before :binwrite do |path, *| + Helpers.scan(path, "binwrite") + end + end + end + end + end +end + +Aikido::Zen::Sinks::IO.load_sinks! diff --git a/lib/aikido/zen/sinks/pathname.rb b/lib/aikido/zen/sinks/pathname.rb new file mode 100644 index 00000000..39e42126 --- /dev/null +++ b/lib/aikido/zen/sinks/pathname.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "pathname" + +module Aikido::Zen + module Sinks + module Pathname + SINK = Sinks.add("Pathname", scanners: [Scanners::PathTraversalScanner]) + + module Helpers + def self.scan(filepath, operation) + SINK.scan( + filepath: filepath, + operation: operation + ) + end + end + + def self.load_sinks! + ::Pathname.class_eval do + extend Sinks::DSL + + sink_before :cleanpath do + Aikido::Zen::Sinks::Pathname::Helpers.scan(to_s, "cleanpath") + end + end + end + end + end +end + +Aikido::Zen::Sinks::Pathname.load_sinks! diff --git a/test/aikido/zen/sinks/file_test.rb b/test/aikido/zen/sinks/file_test.rb index e65b66aa..25092ff8 100644 --- a/test/aikido/zen/sinks/file_test.rb +++ b/test/aikido/zen/sinks/file_test.rb @@ -192,6 +192,10 @@ class NormalExecutionTest < ActiveSupport::TestCase assert_equal "/some-path", File.expand_path("../some-path/this-wont-appear/..", "/") end + test "File.absolute_path" do + assert File.absolute_path("../some-path", "/base").end_with?("some-path") + end + test "File.realpath" do assert File.realpath("../../sinks", __FILE__).end_with?("test/aikido/zen/sinks") end @@ -325,6 +329,13 @@ class LookLikeAttackTest < ActiveSupport::TestCase end end + test "File.absolute_path" do + refute_attack do + result = File.absolute_path(LOOKS_LIKE_AN_ATTACK_PATH, __FILE__) + assert result.end_with?("looks-like-an-attack") + end + end + test "File.realpath" do refute_attack do assert_raise(Errno::ENOTDIR, Errno::ENOENT) do @@ -429,6 +440,10 @@ def assert_path_traversal_attack(operation, &block) File.expand_path OFFENDER_PATH end + assert_path_traversal_attack "File.absolute_path" do + File.absolute_path OFFENDER_PATH + end + assert_path_traversal_attack "File.realpath" do File.realpath OFFENDER_PATH end diff --git a/test/aikido/zen/sinks/io_test.rb b/test/aikido/zen/sinks/io_test.rb new file mode 100644 index 00000000..2afd5b48 --- /dev/null +++ b/test/aikido/zen/sinks/io_test.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require "test_helper" + +class Aikido::Zen::Sinks::IOTest < ActiveSupport::TestCase + class NormalExecutionTest < ActiveSupport::TestCase + include StubsCurrentContext + include SinkAttackHelpers + + test "IO.read" do + Helpers.temp_file do |tmp_file| + tmp_file.write "some content" + tmp_file.close + assert_equal "some content", IO.read(tmp_file.path) + end + end + + test "IO.write" do + path = Helpers.temp_file_name "io-sink-write" + IO.write path, "io-sink-write" + assert_equal "io-sink-write", IO.read(path) + File.unlink path + end + + test "IO.foreach" do + Helpers.temp_file do |tmp_file| + tmp_file.write "line1\nline2\n" + tmp_file.close + lines = [] + IO.foreach(tmp_file.path) { |l| lines << l } + assert_equal ["line1\n", "line2\n"], lines + end + end + + test "IO.readlines" do + Helpers.temp_file do |tmp_file| + tmp_file.write "line1\nline2\n" + tmp_file.close + assert_equal ["line1\n", "line2\n"], IO.readlines(tmp_file.path) + end + end + + test "IO.binread" do + Helpers.temp_file do |tmp_file| + tmp_file.write "binary" + tmp_file.close + assert_equal "binary", IO.binread(tmp_file.path) + end + end + + test "IO.binwrite" do + path = Helpers.temp_file_name "io-sink-binwrite" + IO.binwrite path, "binary" + assert_equal "binary", IO.binread(path) + File.unlink path + end + end + + class LookLikeAttackTest < ActiveSupport::TestCase + include StubsCurrentContext + include SinkAttackHelpers + + LOOKS_LIKE_AN_ATTACK_PATH = "../looks-like-an-attack" + + test "IO.read" do + refute_attack do + assert_raise Errno::ENOENT do + IO.read(LOOKS_LIKE_AN_ATTACK_PATH) + end + end + end + + test "IO.write" do + refute_attack do + assert_raise Errno::ENOENT do + IO.write Helpers.temp_file_name + "/" + LOOKS_LIKE_AN_ATTACK_PATH, "content" + end + end + end + + test "IO.foreach" do + refute_attack do + assert_raise Errno::ENOENT do + IO.foreach(LOOKS_LIKE_AN_ATTACK_PATH) { } + end + end + end + + test "IO.readlines" do + refute_attack do + assert_raise Errno::ENOENT do + IO.readlines(LOOKS_LIKE_AN_ATTACK_PATH) + end + end + end + + test "IO.binread" do + refute_attack do + assert_raise Errno::ENOENT do + IO.binread(LOOKS_LIKE_AN_ATTACK_PATH) + end + end + end + + test "IO.binwrite" do + refute_attack do + assert_raise Errno::ENOENT do + IO.binwrite Helpers.temp_file_name + "/" + LOOKS_LIKE_AN_ATTACK_PATH, "content" + end + end + end + end + + class AttackDetectionTest < ActiveSupport::TestCase + include StubsCurrentContext + include SinkAttackHelpers + + OFFENDER_PATH = "../this-is-an-attack" + + def assert_path_traversal_attack(operation, &block) + set_context_from_request_to "/?filename=#{OFFENDER_PATH}" + + error = assert_attack Aikido::Zen::Attacks::PathTraversalAttack, &block + + assert_equal operation, error.attack.operation + end + + test "attacks are detected by the scanner" do + assert_path_traversal_attack "IO.read" do + IO.read OFFENDER_PATH + end + + assert_path_traversal_attack "IO.write" do + IO.write OFFENDER_PATH, "content" + end + + assert_path_traversal_attack "IO.foreach" do + IO.foreach(OFFENDER_PATH) { } + end + + assert_path_traversal_attack "IO.readlines" do + IO.readlines OFFENDER_PATH + end + + assert_path_traversal_attack "IO.binread" do + IO.binread OFFENDER_PATH + end + + assert_path_traversal_attack "IO.binwrite" do + IO.binwrite OFFENDER_PATH, "content" + end + end + end + + module Helpers + def self.temp_file_name(basename = "io-sink-temp-file") + ::Dir::Tmpname.create(basename, Dir.tmpdir) { |path| return path } + end + + def self.temp_file(filename = "io-sink-temp-file", &block) + tmp_file = Tempfile.new filename + begin + yield tmp_file + tmp_file.close + ensure + tmp_file.unlink + end + end + end +end diff --git a/test/aikido/zen/sinks/pathname_test.rb b/test/aikido/zen/sinks/pathname_test.rb new file mode 100644 index 00000000..fa2f7445 --- /dev/null +++ b/test/aikido/zen/sinks/pathname_test.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "test_helper" +require "pathname" + +class Aikido::Zen::Sinks::PathnameTest < ActiveSupport::TestCase + class NormalExecutionTest < ActiveSupport::TestCase + include StubsCurrentContext + include SinkAttackHelpers + + test "Pathname#cleanpath with a normal relative path" do + refute_attack do + result = Pathname.new("some/./path/../file.txt").cleanpath + assert_equal Pathname.new("some/file.txt"), result + end + end + + test "Pathname#cleanpath with an absolute path" do + refute_attack do + result = Pathname.new("/var/app/data/file.txt").cleanpath + assert_equal Pathname.new("/var/app/data/file.txt"), result + end + end + end + + class LookLikeAttackTest < ActiveSupport::TestCase + include StubsCurrentContext + include SinkAttackHelpers + + test "Pathname#cleanpath with a traversal path but no context" do + refute_attack do + result = Pathname.new("../../../../etc/passwd").cleanpath + assert_equal Pathname.new("../../../../etc/passwd"), result + end + end + end + + class AttackDetectionTest < ActiveSupport::TestCase + include StubsCurrentContext + include SinkAttackHelpers + + OFFENDER_PATH = "../this-is-an-attack" + + def assert_path_traversal_attack(operation, &block) + set_context_from_request_to "/?filename=#{OFFENDER_PATH}" + + error = assert_attack Aikido::Zen::Attacks::PathTraversalAttack, &block + + assert_equal operation, error.attack.operation + end + + test "Pathname#cleanpath detects traversal before it is resolved" do + assert_path_traversal_attack "Pathname.cleanpath" do + Pathname.new(OFFENDER_PATH).cleanpath + end + end + + test "detects traversal in paths joined with a base before cleanpath" do + set_context_from_request_to "/?filename=#{OFFENDER_PATH}" + + assert_attack Aikido::Zen::Attacks::PathTraversalAttack do + Pathname.new(File.join("/var/app/uploads", OFFENDER_PATH)).cleanpath + end + end + + test "detects the Pathname.cleanpath bypass used to reach sensitive files" do + raw_input = "../../../../etc/passwd" + set_context_from_request_to "/?filename=#{raw_input}" + + assert_attack Aikido::Zen::Attacks::PathTraversalAttack do + Pathname.new(File.join("/var/app/uploads", raw_input)).cleanpath + end + end + end +end From ad466c49586f0c448cd48dfc72c44b7857a85b4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Wed, 24 Jun 2026 11:55:51 +0200 Subject: [PATCH 2/2] Fix linting --- test/aikido/zen/sinks/io_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/aikido/zen/sinks/io_test.rb b/test/aikido/zen/sinks/io_test.rb index 2afd5b48..841f552d 100644 --- a/test/aikido/zen/sinks/io_test.rb +++ b/test/aikido/zen/sinks/io_test.rb @@ -81,7 +81,7 @@ class LookLikeAttackTest < ActiveSupport::TestCase test "IO.foreach" do refute_attack do assert_raise Errno::ENOENT do - IO.foreach(LOOKS_LIKE_AN_ATTACK_PATH) { } + IO.foreach(LOOKS_LIKE_AN_ATTACK_PATH) {} end end end @@ -135,7 +135,7 @@ def assert_path_traversal_attack(operation, &block) end assert_path_traversal_attack "IO.foreach" do - IO.foreach(OFFENDER_PATH) { } + IO.foreach(OFFENDER_PATH) {} end assert_path_traversal_attack "IO.readlines" do