Skip to main content

Route Mapper

A pentest tool aimed at making source code assisted C# testing a little better.

Problem Statement

I got given a C# code base and the webapp doesn't expose Swagger info and the code base doesn't build. What routes exist with what auth, and are any juicy?


Yes, I got nerd snipped into making a proper tool instead of using string manipulation + Regex.

Features

  1. Reading of C# code via a Concrete Syntax Tree (CST) library. This means we have full access to C# specific context and don't rely on educated guesses or homebrew parsing.
  2. Separation of CST, Routing and Rules. This means if you would like to you can read the raw method data, build rules off of our routing implementation or otherwise easily access the data you require.
  3. Pre-built rules that provide real world value. The kind of things that result in high value findings within 15 minutes.

Usage

The example provided below generates an output folder which contains two nested folders, rules and controllers.

The controllers folder contains a file per C# Controller found within source code. This provides generic information on routing but may be overwhelming on its own in larger code bases.

To this end the rules folder provides a file per rule. Rules provide a description of how to interpret the content as well as the results of the rule itself. These are more useful at a glance and provide the primary recommended starting point.

Install with: pip install route-mapper or uv add route-mapper

Copy the following file and set base_path to the path of where the Controllers folder is located.

import json
import sys
from pathlib import Path

from skelmis import route_mapper
from skelmis.route_mapper import rules


def main():
    base_path: Path = Path(
        "/path/to/folder/of/Controllers"
    )
    api_classes: list[route_mapper.transform.APIClass] = []
    output_folder: Path = Path("output")
    controllers_folder = output_folder / "controllers"
    rules_folder = output_folder / "rules"
    controllers_folder.mkdir(parents=True, exist_ok=True)
    rules_folder.mkdir(parents=True, exist_ok=True)

    for file in base_path.rglob("**/*Controller.cs"):
        file_content = file.read_text()
        try:
            api_class: route_mapper.ast.APIClass = route_mapper.file_to_api_class(file_content)
            route_class: route_mapper.transform.APIClass = route_mapper.transform_ast_to_routes(
                api_class
            )
        except:
            print(f"Error while working on file {file}, skipping", file=sys.stderr)
            continue
            
        api_classes.append(route_class)
        with open(output_folder / "controllers" / f"{file.name}.json", "w") as f:
            f.write(json.dumps(route_class.as_dict(), indent=4))

    implicit_routes: rules.ImplicitRoutes = rules.get_implicit_routes(*api_classes)
    with open(output_folder / "rules" / f"implicit_routes.json", "w") as f:
        f.write(json.dumps(implicit_routes.as_dict(), indent=4))

    policy_grouped_routes: rules.RoutesPerAuthorisationPolicy = rules.get_routes_group_by_authz(*api_classes)
    with open(output_folder / "rules" / f"policy_grouped_routes.json", "w") as f:
        f.write(json.dumps(policy_grouped_routes.as_dict(), indent=4))

    print("Done")


if __name__ == "__main__":
    main()

Once run this will generate the folders mentioned prior. Enjoy!


For an example of how to retrieve the raw results from the CST level, please refer to the below script.

import json
from pathlib import Path

from skelmis import route_mapper


def main():
    base_path: Path = Path(
        "/path/to/controllers/folder"
    )
    output_folder: Path = Path("output")
    output_folder.mkdir(parents=True, exist_ok=True)
    for file in base_path.rglob("**/*Controller.cs"):
        file_content = file.read_text()
        api_class: route_mapper.ast.APIClass = route_mapper.file_to_api_class(file_content)
        with open(output_folder / f"{file.name}.json", "w") as f:
            f.write(json.dumps(api_class.as_dict(), indent=4))

    print("Done")


if __name__ == "__main__":
    main()

If run on the following example file:

using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace WebApplication1.Controllers;

[ApiController]
[Route("/api/[controller]")]
public class WeatherForecastController : ControllerBase
{
    private static readonly string[] Summaries = new[]
    {
        "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
    };

    private readonly ILogger<WeatherForecastController> _logger;

    public WeatherForecastController(ILogger<WeatherForecastController> logger)
    {
        _logger = logger;
    }

    [HttpGet(Name = "GetWeatherForecast"), AllowAnonymous]
    public IEnumerable<WeatherForecast> GetBase()
    {
        return Enumerable.Range(1, 5).Select(index => new WeatherForecast
            {
                Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
                TemperatureC = Random.Shared.Next(-20, 55),
                Summary = Summaries[Random.Shared.Next(Summaries.Length)]
            })
            .ToArray();
    }
    
    [HttpGet]
    [Route("woah")]
    public IEnumerable<WeatherForecast> Get(int? max = 5)
    {
        return Enumerable.Range(1, max).Select(index => new WeatherForecast
            {
                Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
                TemperatureC = Random.Shared.Next(-20, 55),
                Summary = Summaries[Random.Shared.Next(Summaries.Length)]
            })
            .ToArray();
    }    
    [HttpPost]
    [Authorize("ManagementAccess")]
    [Route("woah")]
    public IEnumerable<WeatherForecast> Post([FromBody] int? limit, [FromQuery][Range(1, 10, ErrorMessage = "Expected 1-10")] int page = 5)
    {
        return Enumerable.Range(1, 5).Select(index => new WeatherForecast
            {
                Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
                TemperatureC = Random.Shared.Next(-20, 55),
                Summary = Summaries[Random.Shared.Next(Summaries.Length)]
            })
            .ToArray();
    }
    
    public IEnumerable<WeatherForecast> OopsItsPublic()
    {
        return Enumerable.Range(1, 5).Select(index => new WeatherForecast
            {
                Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
                TemperatureC = Random.Shared.Next(-20, 55),
                Summary = Summaries[Random.Shared.Next(Summaries.Length)]
            })
            .ToArray();
    }
    
    private IEnumerable<WeatherForecast> PrivateMethod()
    {
        return Enumerable.Range(1, 5).Select(index => new WeatherForecast
            {
                Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
                TemperatureC = Random.Shared.Next(-20, 55),
                Summary = Summaries[Random.Shared.Next(Summaries.Length)]
            })
            .ToArray();
    }
}

The following file output is generated for at a glance review:

{
    "class_name": "WeatherForecastController",
    "is_public_class": true,
    "attributes": [
        {
            "name": "ApiController",
            "arguments": null
        },
        {
            "name": "Route",
            "arguments": [
                "/api/[controller]"
            ]
        }
    ],
    "methods": [
        {
            "method_name": "GetBase",
            "is_public_method": true,
            "return_type": "IEnumerable<WeatherForecast>",
            "arguments": [],
            "attributes": [
                {
                    "name": "HttpGet",
                    "arguments": [
                        "Name = GetWeatherForecast"
                    ]
                },
                {
                    "name": "AllowAnonymous",
                    "arguments": null
                }
            ]
        },
        {
            "method_name": "Get",
            "is_public_method": true,
            "return_type": "IEnumerable<WeatherForecast>",
            "arguments": [
                {
                    "argument_type": "int",
                    "argument_name": "max",
                    "is_nullable": true,
                    "has_default_argument": true,
                    "argument_default": "5",
                    "attributes": []
                }
            ],
            "attributes": [
                {
                    "name": "HttpGet",
                    "arguments": null
                },
                {
                    "name": "Route",
                    "arguments": [
                        "woah"
                    ]
                }
            ]
        },
        {
            "method_name": "Post",
            "is_public_method": true,
            "return_type": "IEnumerable<WeatherForecast>",
            "arguments": [
                {
                    "argument_type": "int",
                    "argument_name": "limit",
                    "is_nullable": true,
                    "has_default_argument": false,
                    "argument_default": null,
                    "attributes": [
                        {
                            "name": "FromBody",
                            "arguments": null
                        }
                    ]
                },
                {
                    "argument_type": "int",
                    "argument_name": "page",
                    "is_nullable": false,
                    "has_default_argument": true,
                    "argument_default": "5",
                    "attributes": [
                        {
                            "name": "FromQuery",
                            "arguments": null
                        },
                        {
                            "name": "Range",
                            "arguments": [
                                "1",
                                "10",
                                "ErrorMessage = Expected 1-10"
                            ]
                        }
                    ]
                }
            ],
            "attributes": [
                {
                    "name": "HttpPost",
                    "arguments": null
                },
                {
                    "name": "Authorize",
                    "arguments": [
                        "ManagementAccess"
                    ]
                },
                {
                    "name": "Route",
                    "arguments": [
                        "woah"
                    ]
                }
            ]
        },
        {
            "method_name": "OopsItsPublic",
            "is_public_method": true,
            "return_type": "IEnumerable<WeatherForecast>",
            "arguments": [],
            "attributes": []
        },
        {
            "method_name": "PrivateMethod",
            "is_public_method": false,
            "return_type": "IEnumerable<WeatherForecast>",
            "arguments": [],
            "attributes": []
        }
    ]
}

Gotchas

  • Everything is a string. It's in the source as 1? Cool, now it's "1". Types are hard and too complicated for this use-case.
  • Inheritance is not handled. If your API has parent class functionality, this will not see it.

Metadata

Release files for route-mapper 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for route-mapper 0.3.0
File Size Uploaded
route_mapper-0.3.0.tar.gz 10.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for route-mapper 0.3.0
File Interpreter ABI Platform
route_mapper-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 20.7 kB

Release files / route_mapper-0.3.0.tar.gz

Download URL route_mapper-0.3.0.tar.gz
Size 10.5 kB
Tags Source
SHA-256 checksum
How to use checksums
136920cc65287385c84eaa7685a5fc3103fcbb0ce980c22530c73046dca8a16f
BLAKE2b-256 checksum
How to use checksums
7eb01715a7429cd4f22190c916bb2305c4b79ce3afb92b7548e9eadf490419c4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.11.6 {"installer":{"name":"uv","version":"0.11.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / route_mapper-0.3.0-py3-none-any.whl

Download URL route_mapper-0.3.0-py3-none-any.whl
Size 10.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
6dc56b64660bd7b0065aa97d8adf7251761744f2660b211ad5dea1547ad1a956
BLAKE2b-256 checksum
How to use checksums
5a01d54d7be9f616377a118b478a381183fbed2b306780157dd1efe9eb88300b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.11.6 {"installer":{"name":"uv","version":"0.11.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.0

2 release files

0.0.5

2 release files

0.0.4

2 release files

0.0.3

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page