draggable topology + attack visualizer + tools in containers

- topology nodes draggable (pointer events, SVG transform), layout hint shown
- attack visualizer: /api/attacks logs attacker->target events; red pulsing
  dashed arcs on recent attacks (60s hot), ⚔ counts ok/fail
- submit_flag now takes attacker_idx vs target_idx (A/D semantics); UI has
  target dropdown (enemy teams), leaderboard records target
- containers get vim+curl+wget+netcat+git+pip3 (Dockerfiles blogpost/cdn/
  phew/sheesh/warmup); warmup base ubuntu:20.04 EOL -> 24.04
- team portal: target dropdown refreshed after login (was empty pre-auth)
This commit is contained in:
root
2026-09-23 18:05:44 +08:00
parent 44dc1ac852
commit 24dbf0a662
9 changed files with 196 additions and 23 deletions
+18 -3
View File
@@ -223,6 +223,8 @@ async def api_team_targets(idx: int, req: Request):
if not p: if not p:
continue continue
out.append({ out.append({
"team_idx": st.get("index"),
"team_label": st.get("label", f"Team {st.get('index')}"),
"domain": st.get("domain") or (st.get("slug", f"team{st.get('index')}") + ".gemastik.imrnes.team"), "domain": st.get("domain") or (st.get("slug", f"team{st.get('index')}") + ".gemastik.imrnes.team"),
"port": p["chall"], "port": p["chall"],
}) })
@@ -521,9 +523,11 @@ async def api_reset_environment(req: Request):
@app.post("/api/flag/submit") @app.post("/api/flag/submit")
async def api_flag_submit(req: Request): async def api_flag_submit(req: Request):
"""Public endpoint: teams submit flags. No login needed.""" """Public endpoint: teams submit flags. No login needed.
body: {team: attacker, target?: victim, challenge, flag}"""
data = await req.json() data = await req.json()
team = int(data.get("team", 0)) team = int(data.get("team", 0)) # attacker (tim yang submit)
target = int(data.get("target") or 0) or team # victim team (flag dicuri dari sini)
chall = data.get("challenge", "") chall = data.get("challenge", "")
flag = data.get("flag", "").strip() flag = data.get("flag", "").strip()
team_name = data.get("team_name", "").strip()[:64] or f"Team {team}" team_name = data.get("team_name", "").strip()[:64] or f"Team {team}"
@@ -533,7 +537,18 @@ async def api_flag_submit(req: Request):
return {"success": False, "error": "Challenge not found"} return {"success": False, "error": "Challenge not found"}
if not flag: if not flag:
return {"success": False, "error": "Flag is required"} return {"success": False, "error": "Flag is required"}
return orch.submit_flag(team, chall, flag, team_name) return orch.submit_flag(target, chall, flag, attacker_idx=team, attacker_name=team_name)
@app.get("/api/attacks")
async def api_attacks(req: Request):
"""Admin: recent attack events (attacker -> target) for the topology visualizer."""
require_login(req)
ap = orch.TEAMS_DIR / "attacks.json"
if ap.exists():
log = json.loads(ap.read_text())
else:
log = {"events": []}
return {"events": log.get("events", [])}
@app.get("/api/leaderboard") @app.get("/api/leaderboard")
+85 -8
View File
@@ -101,6 +101,12 @@
@keyframes sp { to { transform:rotate(360deg); } } @keyframes sp { to { transform:rotate(360deg); } }
.topo-wrap { background:#0a0f1c; border:1px solid #1e3a5f; border-radius:12px; padding:20px; overflow-x:auto; } .topo-wrap { background:#0a0f1c; border:1px solid #1e3a5f; border-radius:12px; padding:20px; overflow-x:auto; }
.topo-svg { width:100%; min-width:800px; } .topo-svg { width:100%; min-width:800px; }
/* attack visualizer: recent attacks pulse */
@keyframes attackPulse { 0%,100% { stroke-opacity:0.4; } 50% { stroke-opacity:1; } }
@keyframes attackBlink { 0%,100% { opacity:1; } 50% { opacity:0.35; } }
.attack-line { animation: attackPulse 1.2s ease-in-out infinite; }
.attack-icon { animation: attackBlink 1s ease-in-out infinite; }
.topo-hint { font-size:12px; color:var(--dim); margin-top:8px; }
.team-head { display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; } .team-head { display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; }
.team-status { font-size:11px; } .team-status { font-size:11px; }
.kv { display:grid; grid-template-columns:120px 1fr; gap:6px 12px; font-size:12px; } .kv { display:grid; grid-template-columns:120px 1fr; gap:6px 12px; font-size:12px; }
@@ -137,7 +143,8 @@
<!-- Topology view --> <!-- Topology view -->
<div class="view" id="view-topo"> <div class="view" id="view-topo">
<div class="topo-wrap"><svg id="topoSvg" class="topo-svg" height="420"></svg></div> <div class="topo-wrap"><svg id="topoSvg" class="topo-svg" height="520"></svg></div>
<div class="topo-hint">💡 Geser node untuk mengatur layout • ⚔️ garis merah = serangan (panah attacker → target) • garis <b>putus-putus</b> = serangan baru (60 detik terakhir)</div>
</div> </div>
<!-- Teams view --> <!-- Teams view -->
@@ -283,7 +290,15 @@ function esc(s) {
function showView(v) { function showView(v) {
document.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.view === v)); document.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.view === v));
document.querySelectorAll('.view').forEach(x => x.classList.toggle('active', x.id === 'view-' + v)); document.querySelectorAll('.view').forEach(x => x.classList.toggle('active', x.id === 'view-' + v));
if (v === 'topo') loadTopo(); // refresh attacks immediately on tab switch + every 10s
} }
let topoTimer = null;
function startTopoTimer() {
if (!topoTimer) topoTimer = setInterval(() => {
if (document.getElementById('view-topo').classList.contains('active')) loadTopo();
}, 10000);
}
startTopoTimer();
// ---------- Challenges ---------- // ---------- Challenges ----------
async function refresh() { async function refresh() {
@@ -371,9 +386,11 @@ async function viewCred(ch) {
// ---------- Topology ---------- // ---------- Topology ----------
let topoLoaded = false; let topoLoaded = false;
let topoAttacks = [];
async function loadTopo() { async function loadTopo() {
try { try {
const d = await api('/api/topology'); const [d, a] = await Promise.all([api('/api/topology'), api('/api/attacks').catch(() => ({events: []}))]);
topoAttacks = a.events || [];
renderTopo(d.nodes, d.edges); renderTopo(d.nodes, d.edges);
} catch (e) { toast('Topologi gagal: ' + e.message, true); } } catch (e) { toast('Topologi gagal: ' + e.message, true); }
} }
@@ -381,7 +398,7 @@ async function loadTopo() {
function renderTopo(nodes, edges) { function renderTopo(nodes, edges) {
const svg = document.getElementById('topoSvg'); const svg = document.getElementById('topoSvg');
const W = Math.max(900, nodes.length * 130); const W = Math.max(900, nodes.length * 130);
const H = 400; const H = 500;
svg.setAttribute('width', W); svg.setAttribute('height', H); svg.setAttribute('width', W); svg.setAttribute('height', H);
const cx = W / 2; const cx = W / 2;
const ns = {}; const ns = {};
@@ -398,15 +415,26 @@ function renderTopo(nodes, edges) {
const ang = (i / Math.max(1, teams.length)) * Math.PI * 2 - Math.PI / 2; const ang = (i / Math.max(1, teams.length)) * Math.PI * 2 - Math.PI / 2;
const rx = W * 0.36, ry = 100; const rx = W * 0.36, ry = 100;
const tx = cx + rx * Math.cos(ang); const tx = cx + rx * Math.cos(ang);
const ty = 210 + ry * Math.sin(ang) * 0.6; const ty = 250 + ry * Math.sin(ang) * 0.6;
teamPos[t.index] = {x: tx, y: ty}; teamPos[t.index] = {x: tx, y: ty};
pos[t.id] = {x: tx, y: ty}; pos[t.id] = {x: tx, y: ty};
}); });
nodes.filter(n => n.type === 'challenge').forEach(n => { nodes.filter(n => n.type === 'challenge').forEach(n => {
const ti = n.id.split('-')[0].replace('team',''); const ti = n.id.split('-')[0].replace('team','');
const base = teamPos[ti] || {x: cx, y: 250}; const base = teamPos[ti] || {x: cx, y: 300};
const chIdx = ['blogpost','carbeat','cdn','phew','sheesh','warmup'].indexOf(n.label.split('-').pop() || n.label); const chIdx = ['blogpost','carbeat','cdn','phew','sheesh','warmup'].indexOf(n.label.split('-').pop() || n.label);
pos[n.id] = {x: base.x + (chIdx - 2.5) * 70, y: base.y + 110}; pos[n.id] = {x: base.x + (chIdx - 2.5) * 70, y: base.y + 130};
});
// ---- attack arcs (attacker team -> target team), recent only (last 10 min) ----
const now = Date.now() / 1000;
const recentAttacks = topoAttacks.filter(e => now - (e.ts || 0) < 600);
const attackCounts = {}; // "attacker:target" -> {ok, fail, latest}
recentAttacks.forEach(e => {
const k = `${e.attacker}:${e.target}`;
attackCounts[k] = attackCounts[k] || {ok: 0, fail: 0, latest: e.ts};
attackCounts[k][e.success ? 'ok' : 'fail']++;
attackCounts[k].latest = Math.max(attackCounts[k].latest, e.ts || 0);
}); });
// edges // edges
@@ -417,10 +445,24 @@ function renderTopo(nodes, edges) {
html += `<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="#2a4a6f" stroke-width="1.5" stroke-dasharray="4 3"/>`; html += `<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="#2a4a6f" stroke-width="1.5" stroke-dasharray="4 3"/>`;
if (e.label) { if (e.label) {
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2 - 6; const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2 - 6;
html += `<text x="${mx}" y="${my}" fill="#5a7a9f" font-size="9" text-anchor="middle">${e.label}</text>`; html += `<text x="${mx}" y="${my}" fill="#5a7a9f" font-size="9" text-anchor="middle">${esc(e.label)}</text>`;
} }
}); });
// ---- attack visualizer ----
for (const [k, c] of Object.entries(attackCounts)) {
const [atk, tgt] = k.split(':');
const a = pos['team' + atk], b = pos['team' + tgt];
if (!a || !b || atk === tgt) continue;
const isHot = now - c.latest < 60;
const color = c.ok > 0 ? '#f87171' : '#fb923c';
const dash = isHot ? '6 3' : '4 4';
html += `<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="${color}" stroke-width="${isHot ? 3 : 2}" stroke-dasharray="${dash}" opacity="0.9" class="attack-line"/>`;
const mx = (a.x + b.x) / 2 + 8, my = (a.y + b.y) / 2 - 10;
const icon = isHot ? '⚔️' : '⚔';
html += `<text x="${mx}" y="${my}" fill="${color}" font-size="12" text-anchor="middle" class="attack-icon">${icon} ${c.ok}✓ ${c.fail}✗</text>`;
}
// nodes // nodes
nodes.forEach(n => { nodes.forEach(n => {
const p = pos[n.id]; const p = pos[n.id];
@@ -432,7 +474,7 @@ function renderTopo(nodes, edges) {
if (n.type === 'challenge') { r = 26; color = '#c9d4e3'; } if (n.type === 'challenge') { r = 26; color = '#c9d4e3'; }
const status = n.status === 'running' ? ' fill="#34d399"' : ''; const status = n.status === 'running' ? ' fill="#34d399"' : '';
const sub = n.type === 'team' ? `:${n.receiver_port}` : (n.port ? `:${n.port}` : ''); const sub = n.type === 'team' ? `:${n.receiver_port}` : (n.port ? `:${n.port}` : '');
html += `<g> html += `<g data-node="${esc(n.id)}" style="cursor:grab">
<circle cx="${p.x}" cy="${p.y}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/> <circle cx="${p.x}" cy="${p.y}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>
<circle cx="${p.x}" cy="${p.y}" r="${r-4}" fill="none" stroke="${stroke}" stroke-opacity="0.3"/> <circle cx="${p.x}" cy="${p.y}" r="${r-4}" fill="none" stroke="${stroke}" stroke-opacity="0.3"/>
<text x="${p.x}" y="${p.y - (sub ? 0 : 4)}" fill="${color}" font-size="${n.type==='team'?12:10}" font-weight="bold" text-anchor="middle">${esc(n.label)}</text> <text x="${p.x}" y="${p.y - (sub ? 0 : 4)}" fill="${color}" font-size="${n.type==='team'?12:10}" font-weight="bold" text-anchor="middle">${esc(n.label)}</text>
@@ -441,6 +483,41 @@ function renderTopo(nodes, edges) {
</g>`; </g>`;
}); });
svg.innerHTML = `<defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#2a4a6f"/></marker></defs>` + html; svg.innerHTML = `<defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#2a4a6f"/></marker></defs>` + html;
enableTopoDrag(svg, pos);
}
// ---- draggable topology ----
function enableTopoDrag(svg, pos) {
let dragEl = null, dx = 0, dy = 0;
const onMove = (ev) => {
if (!dragEl) return;
const ctm = svg.getScreenCTM();
if (!ctm) return;
const pt = svg.createSVGPoint();
pt.x = ev.clientX; pt.y = ev.clientY;
const p = pt.matrixTransform(ctm.inverse());
dragEl.setAttribute('transform', `translate(${p.x - dx}, ${p.y - dy})`);
};
const onUp = () => { dragEl = null; svg.style.cursor = ''; };
svg.addEventListener('mousedown', (ev) => {
const g = ev.target.closest('g[data-node]');
if (!g) return;
ev.preventDefault();
const ctm = svg.getScreenCTM();
const pt = svg.createSVGPoint();
pt.x = ev.clientX; pt.y = ev.clientY;
const p = pt.matrixTransform(ctm.inverse());
const c = g.querySelector('circle');
const cx = parseFloat(c.getAttribute('cx'));
const cy = parseFloat(c.getAttribute('cy'));
dx = p.x - cx; dy = p.y - cy;
dragEl = g;
svg.style.cursor = 'grabbing';
g.setPointerCapture && g.setPointerCapture(ev.pointerId);
});
svg.addEventListener('pointermove', onMove);
svg.addEventListener('pointerup', onUp);
svg.addEventListener('pointercancel', onUp);
} }
// ---------- Teams ---------- // ---------- Teams ----------
+30 -1
View File
@@ -138,7 +138,11 @@
<option value="sheesh">sheesh (crypto)</option> <option value="sheesh">sheesh (crypto)</option>
<option value="warmup">warmup</option> <option value="warmup">warmup</option>
</select> </select>
<label>Flag</label> <label>Target (tim musuh yang diserang)</label>
<select id="target">
<option value="">Memuat target…</option>
</select>
<label>Flag (yang kamu curi dari target)</label>
<input type="text" id="flag" placeholder="GEMASTIK18{...}" autocomplete="off"> <input type="text" id="flag" placeholder="GEMASTIK18{...}" autocomplete="off">
<button onclick="submitFlag()" style="width:100%;margin-top:16px">Submit Flag</button> <button onclick="submitFlag()" style="width:100%;margin-top:16px">Submit Flag</button>
<div id="result"></div> <div id="result"></div>
@@ -229,6 +233,8 @@ async function doLogin() {
document.getElementById('portalScreen').style.display = 'block'; document.getElementById('portalScreen').style.display = 'block';
document.getElementById('logoutBtn').style.display = ''; document.getElementById('logoutBtn').style.display = '';
loadInfo(); loadInfo();
loadTargetDropdown(); // target dropdown needs auth — refresh after login
loadTargets();
} else { } else {
err.textContent = 'Password salah. Coba lagi.'; err.textContent = 'Password salah. Coba lagi.';
err.style.display = 'block'; err.style.display = 'block';
@@ -305,6 +311,7 @@ async function submitFlag() {
method: 'POST', headers: {'Content-Type': 'application/json'}, method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ body: JSON.stringify({
team: TEAM_ID, team: TEAM_ID,
target: parseInt(document.getElementById('target').value || '0', 10) || TEAM_ID,
challenge: document.getElementById('challenge').value, challenge: document.getElementById('challenge').value,
flag: document.getElementById('flag').value.trim(), flag: document.getElementById('flag').value.trim(),
}), }),
@@ -313,6 +320,28 @@ async function submitFlag() {
else { res.className = 'err'; res.textContent = '❌ ' + (d.error || 'Gagal'); } else { res.className = 'err'; res.textContent = '❌ ' + (d.error || 'Gagal'); }
} }
// target dropdown: all enemy teams (exclude own)
async function loadTargetDropdown() {
try {
const d = await api(`/api/team/${TEAM_ID}/targets`);
const sel = document.getElementById('target');
const targets = (d.targets || []).filter(t => t.team_idx !== TEAM_ID);
const seen = new Map();
for (const t of targets) seen.set(t.team_idx, t.team_label || ('Team ' + t.team_idx));
sel.innerHTML = '';
if (!seen.size) {
sel.innerHTML = '<option value="">Tidak ada tim musuh</option>';
return;
}
for (const [idx, label] of seen) {
const o = document.createElement('option');
o.value = idx; o.textContent = `${label} (tim ${idx})`;
sel.appendChild(o);
}
} catch (e) { /* non-fatal */ }
}
loadTargetDropdown();
checkSession(); checkSession();
</script> </script>
</body> </body>
+39 -9
View File
@@ -374,9 +374,15 @@ def randomize_flags(idx: int) -> dict:
return mapping return mapping
def submit_flag(team_idx: int, chall: str, flag: str, team_name: str = "") -> dict: def submit_flag(target_idx: int, chall: str, flag: str,
"""Validate a submitted flag against the owning team's challenge flag.""" attacker_idx: int = None, attacker_name: str = "") -> dict:
team_dir = TEAMS_DIR / f"team{team_idx}" """Validate a submitted flag against the TARGET team's challenge flag.
A/D semantics: attacker_idx scores by stealing target_idx's flag.
Back-compat: attacker_idx defaults to target_idx (self-submit).
"""
attacker_idx = attacker_idx if attacker_idx else target_idx
team_dir = TEAMS_DIR / f"team{target_idx}"
if not (team_dir / "state.json").exists(): if not (team_dir / "state.json").exists():
return {"success": False, "error": "unknown team"} return {"success": False, "error": "unknown team"}
flags_dir = team_dir / "receiver" / "flags" flags_dir = team_dir / "receiver" / "flags"
@@ -384,23 +390,47 @@ def submit_flag(team_idx: int, chall: str, flag: str, team_name: str = "") -> di
if not expected: if not expected:
return {"success": False, "error": "challenge not found"} return {"success": False, "error": "challenge not found"}
if flag.strip() != expected: if flag.strip() != expected:
_log_attack(attacker_idx, target_idx, chall, flag[:24], success=False)
return {"success": False, "error": "wrong flag"} return {"success": False, "error": "wrong flag"}
# record leaderboard entry _log_attack(attacker_idx, target_idx, chall, flag, success=True)
# record leaderboard entry — score goes to the ATTACKER
lb_path = TEAMS_DIR / "leaderboard.json" lb_path = TEAMS_DIR / "leaderboard.json"
lb = json.loads(lb_path.read_text()) if lb_path.exists() else {"solves": []} lb = json.loads(lb_path.read_text()) if lb_path.exists() else {"solves": []}
entry = { entry = {
"team": team_idx, "team": attacker_idx,
"team_name": team_name or f"Team {team_idx}", "team_name": attacker_name or f"Team {attacker_idx}",
"challenge": chall, "challenge": chall,
"target": target_idx,
"flag": flag, "flag": flag,
"ts": time.time(), "ts": time.time(),
"ts_human": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "ts_human": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
} }
# avoid double-solve duplicates (same flag + same team) # avoid double-solve duplicates (same flag + same attacker team)
if not any(e["team"] == team_idx and e["challenge"] == chall for e in lb["solves"]): if not any(e["team"] == attacker_idx and e["challenge"] == chall
and e.get("target") == target_idx for e in lb["solves"]):
lb["solves"].append(entry) lb["solves"].append(entry)
lb_path.write_text(json.dumps(lb, indent=2)) lb_path.write_text(json.dumps(lb, indent=2))
return {"success": True, "team": team_idx, "challenge": chall} return {"success": True, "team": attacker_idx, "target": target_idx, "challenge": chall}
def _log_attack(attacker_team: int, target_team: int, target_chall: str, flag_hint: str, success: bool):
"""Append a row to the attack log (used by the topology attack visualizer)."""
try:
ap = TEAMS_DIR / "attacks.json"
log = json.loads(ap.read_text()) if ap.exists() else {"events": []}
log["events"].append({
"attacker": attacker_team,
"target": target_team,
"challenge": target_chall,
"flag_hint": flag_hint,
"success": success,
"ts": time.time(),
"ts_human": datetime.now().strftime("%H:%M:%S"),
})
# keep last 200
log["events"] = log["events"][-200:]
ap.write_text(json.dumps(log, indent=2))
except Exception:
pass
def reset_scores() -> dict: def reset_scores() -> dict:
"""Wipe the leaderboard (all solves removed).""" """Wipe the leaderboard (all solves removed)."""
+6
View File
@@ -21,6 +21,12 @@ RUN apt-get update && \
build-essential \ build-essential \
bash \ bash \
nano \ nano \
vim \
curl \
wget \
netcat-openbsd \
git \
python3-pip \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Create an unprivileged user for running the app / SSH access # Create an unprivileged user for running the app / SSH access
+6
View File
@@ -22,6 +22,12 @@ RUN apt-get update && \
build-essential \ build-essential \
bash \ bash \
nano \ nano \
vim \
curl \
wget \
netcat-openbsd \
git \
python3-pip \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Create an unprivileged user for running the app / SSH access # Create an unprivileged user for running the app / SSH access
+5
View File
@@ -12,6 +12,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libssl-dev \ libssl-dev \
python3-dev \ python3-dev \
socat \ socat \
vim \
curl \
wget \
netcat-openbsd \
git \
bash \ bash \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
+5
View File
@@ -12,6 +12,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libssl-dev \ libssl-dev \
python3-dev \ python3-dev \
socat \ socat \
vim \
curl \
wget \
netcat-openbsd \
git \
bash \ bash \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
+2 -2
View File
@@ -6,14 +6,14 @@ COPY src/main.go .
RUN go build -o challenge main.go RUN go build -o challenge main.go
# Final stage # Final stage
FROM public.ecr.aws/docker/library/ubuntu:20.04 FROM public.ecr.aws/docker/library/ubuntu:24.04
ARG PASSWORD ARG PASSWORD
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
# Install necessary packages # Install necessary packages
RUN apt-get update && apt-get install -y nano openssh-server python3 curl netcat-traditional wget sudo nginx golang-go && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y nano vim git openssh-server python3 curl netcat-traditional wget sudo nginx golang-go && rm -rf /var/lib/apt/lists/*
# Create ctfuser and set password # Create ctfuser and set password
RUN useradd -m -d /home/ctfuser ctfuser && echo ctfuser:${PASSWORD} | chpasswd RUN useradd -m -d /home/ctfuser ctfuser && echo ctfuser:${PASSWORD} | chpasswd