Skip to content

Commit 51b5190

Browse files
committed
power pages module
1 parent 7a694b5 commit 51b5190

File tree

6 files changed

+179
-1
lines changed

6 files changed

+179
-1
lines changed

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,5 @@ typing_extensions<4.6.0
2727
pyjwt~=2.6.0
2828
websockets~=12.0.0
2929
pandas
30-
xlsxwriter~=3.2.0
30+
xlsxwriter~=3.2.0
31+
xmltodict

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ install_requires =
2727
websockets >= 12.0.0
2828
pandas
2929
xlsxwriter >= 3.2.0
30+
xmltodict
3031

3132
package_dir =
3233
= src

src/powerpwn/cli/arguments.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,19 @@ def copilot_studio_modules(parser: argparse.ArgumentParser, module: str):
227227
parser.add_argument("-t", "--timeout", help="The timeout for the enumeration process to have (in seconds)", default=300)
228228

229229

230+
def module_powerpages(parser: argparse.ArgumentParser):
231+
powerpages = parser.add_parser(
232+
"powerpages",
233+
description="Test anonymous access to dataverse tables via power pages, either via the apis or odata feeds.",
234+
help="Test anonymous access to dataverse tables via power pages, either via the apis or odata feeds.",
235+
)
236+
powerpages.add_argument(
237+
"-url",
238+
help="The url of the power pages domain to be tested. Format the url as such: 'https://<your_domain>.powerappsportals.com'",
239+
required=True,
240+
)
241+
242+
230243
def parse_arguments():
231244
parser = argparse.ArgumentParser()
232245
parser.add_argument("-l", "--log-level", default=logging.INFO, type=lambda x: getattr(logging, x), help="Configure the logging level.")
@@ -240,6 +253,7 @@ def parse_arguments():
240253
module_phishing(command_subparsers)
241254
module_copilot(command_subparsers)
242255
module_copilot_studio(command_subparsers)
256+
module_powerpages(command_subparsers)
243257

244258
args = parser.parse_args()
245259

src/powerpwn/cli/runners.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from powerpwn.powerdump.utils.auth import Auth, acquire_token, acquire_token_from_cached_refresh_token, get_cached_tenant
3030
from powerpwn.powerdump.utils.const import API_HUB_SCOPE, POWER_APPS_SCOPE
3131
from powerpwn.powerdump.utils.path_utils import collected_data_path, entities_path
32+
from powerpwn.powerpages.powerpages import PowerPages
3233
from powerpwn.powerphishing.app_installer import AppInstaller
3334

3435
logger = logging.getLogger(LOGGER_NAME)
@@ -225,3 +226,7 @@ def run_copilot_studio_command(args):
225226
return
226227

227228
raise NotImplementedError("Copilot studio command has not been implemented yet.")
229+
230+
231+
def run_powerpages_command(args):
232+
PowerPages(args)

src/powerpwn/main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
run_gui_command,
1313
run_nocodemalware_command,
1414
run_phishing_command,
15+
run_powerpages_command,
1516
run_recon_command,
1617
)
1718

@@ -52,6 +53,8 @@ def main():
5253
run_copilot_chat_command(args)
5354
elif command == "copilot-studio-hunter":
5455
run_copilot_studio_command(args)
56+
elif command == "powerpages":
57+
run_powerpages_command(args)
5558
else:
5659
logger.info("Run `powerpwn --help` for available commands.")
5760

src/powerpwn/powerpages/powerpages.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import requests
2+
import xmltodict
3+
4+
5+
class PowerPages:
6+
"""
7+
A class that is responsible for the Power Pages scan
8+
"""
9+
10+
def __init__(self, args):
11+
self.args = args
12+
url = self.normalize_url(args.url)
13+
self.url = url
14+
self.scan()
15+
16+
def normalize_url(self, url):
17+
if not url.startswith("https://"):
18+
url = "https://" + url
19+
if url.endswith("/"):
20+
url = url[:-1]
21+
url = url.replace("www.", "")
22+
return url
23+
24+
def scan(self):
25+
url = self.url
26+
print(f"Checking `{url}`")
27+
try:
28+
odata_tables, api_tables = self.get_odata_tables()
29+
if len(odata_tables) == 0:
30+
print("You are safe!")
31+
else:
32+
print(f"Found {len(odata_tables)} open tables in '{url}'.")
33+
if len(api_tables):
34+
print(f"Examining additional {len(api_tables)} potential tables through the api")
35+
print("\nChecking each table:")
36+
for table in odata_tables:
37+
try:
38+
self.get_odata_table_data(table)
39+
except Exception:
40+
print(f"Can't access table {table['name']} through the odata right now")
41+
for table in api_tables:
42+
try:
43+
self.get_api_table_data(table)
44+
except Exception:
45+
print(f"Can't access table {table['name']} through the api right now")
46+
except Exception:
47+
print(f"Can't access `{url}` anonymously")
48+
49+
def get_odata_tables(self):
50+
url = self.url
51+
odata_url = f"{url}/_odata/$metadata"
52+
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"}
53+
resp = requests.get(odata_url, headers=headers, timeout=10)
54+
odata_tables = []
55+
api_tables = []
56+
if 200 <= resp.status_code <= 299:
57+
content_xml = resp.content.decode("utf-8")
58+
resp_json = xmltodict.parse(content_xml)
59+
entity_types = resp_json.get("edmx:Edmx", {}).get("edmx:DataServices", {}).get("Schema", {}).get("EntityType", [])
60+
entity_sets = (
61+
resp_json.get("edmx:Edmx", {}).get("edmx:DataServices", {}).get("Schema", {}).get("EntityContainer", {}).get("EntitySet", [])
62+
)
63+
table_names = {entity_set["@EntityType"][4:]: entity_set["@Name"] for entity_set in entity_sets}
64+
65+
for entity in entity_types:
66+
name = table_names.get(entity.get("@Name", "unknown"), "unknown")
67+
key = entity.get("Key", {}).get("PropertyRef", {}).get("@Name", "unknown")
68+
columns = [prop.get("@Name", "unknown") for prop in entity.get("Property", [])]
69+
odata_tables.append({"name": name, "key": key, "columns": columns})
70+
if name.endswith("Set"):
71+
api_table_name = name.lower()[:-3] + "s"
72+
api_tables.append({"name": api_table_name, "key": key, "columns": columns})
73+
74+
return odata_tables, api_tables
75+
76+
def get_api_table_data(self, table):
77+
url = self.url
78+
table_name = table["name"]
79+
table_columns = table["columns"]
80+
api_table_url = f"{url}/_api/{table_name}?$top=1"
81+
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"}
82+
resp = requests.get(api_table_url, headers=headers, timeout=10)
83+
if 200 <= resp.status_code <= 299:
84+
resp_json = resp.json()
85+
if len(resp_json["value"]) > 0:
86+
print(f"Table {table_name} is exposed with all columns through the API!")
87+
return len(table_columns)
88+
else:
89+
print(f"Table {table_name} is accessible but returned no data through the API!")
90+
return 0
91+
elif resp.status_code in [400, 403]:
92+
try:
93+
resp_json = resp.json()
94+
error_code = resp_json.get("error", {}).get("code", "")
95+
if not error_code == "90040101":
96+
raise Exception("Unknown error code")
97+
except Exception:
98+
print(f"Table {table_name} data is safe through the API")
99+
return
100+
print(f"Table {table_name} data is not exposed as a whole, checking each column...")
101+
exposed = []
102+
for column in table_columns:
103+
api_column_url = f"{url}/_api/{table_name}?select={column}&$top=1"
104+
resp = requests.get(api_column_url, headers=headers, timeout=5)
105+
if 200 <= resp.status_code <= 299:
106+
resp_json = resp.json()
107+
if len(resp_json["value"]) > 0:
108+
exposed.append(column)
109+
if len(exposed):
110+
print(f"Table {table_name} has the following columns exposed: {', '.join(exposed)} through the API")
111+
return len(exposed)
112+
else:
113+
print(f"Table {table_name} data is safe through the API")
114+
return 0
115+
116+
def get_odata_table_data(self, table):
117+
url = self.url
118+
table_name = table["name"]
119+
table_columns = table["columns"]
120+
table_url = f"{url}/_odata/{table_name}?$top=1"
121+
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"}
122+
resp = requests.get(table_url, headers=headers, timeout=10)
123+
if 200 <= resp.status_code <= 299:
124+
resp_json = resp.json()
125+
if len(resp_json["value"]) > 0:
126+
print(f"Table {table_name} is exposed with all columns through the odata!")
127+
return len(table_columns)
128+
else:
129+
print(f"Table {table_name} is accessible but returned no data through the odata!")
130+
return 0
131+
elif resp.status_code in [400, 403]:
132+
try:
133+
resp_json = resp.json()
134+
error_code = resp_json.get("error", {}).get("code", "")
135+
if not error_code == "90040101":
136+
raise Exception("Unknown error code")
137+
except Exception:
138+
print(f"Table {table_name} data is safe through the odata")
139+
return
140+
print(f"Table {table_name} data is not exposed as a whole, checking each column...")
141+
exposed = []
142+
for column in table_columns:
143+
column_url = f"{url}/_odata/{table_name}?select={column}&$top=1"
144+
resp = requests.get(column_url, headers=headers, timeout=5)
145+
if 200 <= resp.status_code <= 299:
146+
resp_json = resp.json()
147+
if len(resp_json["value"]) > 0:
148+
exposed.append(column)
149+
if len(exposed):
150+
print(f"Table {table_name} has the following columns exposed: {', '.join(exposed)} through the odata")
151+
return len(exposed)
152+
else:
153+
print(f"Table {table_name} data is safe through the odata")
154+
return 0

0 commit comments

Comments
 (0)