1. Fixed: Broken Authentication (replaced static tokens with signed JWT-like tokens and persistent secret).

2. Fixed: Default Admin Security (the admin:admin account is now disabled once another administrator is created).
   3. Fixed: Information Disclosure (sensitive team data is now filtered out for non-admins).
   4. Fixed: Denial of Service (added type-safe password checks and error handling for hashing functions).
   5. Fixed: SQL Injection (implemented SCHEMA_WHITELIST for database restore validation).
   6. Fixed: Path Traversal (sanitized filenames for administrative file uploads).
   7. Preserved: Predictable File URLs (kept as an intentional vulnerability for CTF participants).
This commit is contained in:
m0rph3us1987
2026-02-28 14:26:03 +01:00
parent e5f7eca98d
commit 932cdd8a3a

115
server.js
View File

@@ -12,16 +12,52 @@ const port = process.env.PORT || 3000;
// Password Hashing Helpers
function hashPassword(password) {
if (typeof password !== 'string') throw new Error('Password must be a string');
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
return `${salt}:${hash}`;
}
function comparePassword(password, storedPassword) {
if (!storedPassword || !storedPassword.includes(':')) return false;
const [salt, hash] = storedPassword.split(':');
const checkHash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
return hash === checkHash;
if (typeof password !== 'string' || !storedPassword || !storedPassword.includes(':')) return false;
try {
const [salt, hash] = storedPassword.split(':');
const checkHash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
return hash === checkHash;
} catch (e) {
return false;
}
}
// Token Helpers
const secretPath = path.join(__dirname, 'data', 'secret.key');
let JWT_SECRET;
if (fs.existsSync(secretPath)) {
JWT_SECRET = fs.readFileSync(secretPath, 'utf8');
} else {
JWT_SECRET = crypto.randomBytes(32).toString('hex');
if (fs.existsSync(path.join(__dirname, 'data'))) {
fs.writeFileSync(secretPath, JWT_SECRET);
}
}
function generateToken(teamId) {
const hmac = crypto.createHmac('sha256', JWT_SECRET);
hmac.update(teamId);
return `${teamId}.${hmac.digest('hex')}`;
}
function verifyToken(token) {
if (!token) return null;
const parts = token.split('.');
if (parts.length !== 2) return null;
const [teamId, signature] = parts;
const hmac = crypto.createHmac('sha256', JWT_SECRET);
hmac.update(teamId);
if (hmac.digest('hex') === signature) {
return teamId;
}
return null;
}
// Initialize Database
@@ -53,7 +89,7 @@ if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir);
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, uploadDir),
filename: (req, file, cb) => cb(null, Date.now() + '-' + file.originalname)
filename: (req, file, cb) => cb(null, Date.now() + '-' + path.basename(file.originalname))
});
const upload = multer({ storage });
@@ -134,8 +170,14 @@ db.serialize(() => {
db.run(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, [k, v]);
});
const adminPass = hashPassword('admin');
db.run(`INSERT OR IGNORE INTO teams (id, name, password, isAdmin, isDisabled) VALUES ('admin-0', 'admin', ?, 1, 0)`, [adminPass]);
db.get("SELECT COUNT(*) as count FROM teams WHERE isAdmin = 1 AND id != 'admin-0'", (err, row) => {
if (!err && row && row.count === 0) {
const adminPass = hashPassword('admin');
db.run(`INSERT OR IGNORE INTO teams (id, name, password, isAdmin, isDisabled) VALUES ('admin-0', 'admin', ?, 1, 0)`, [adminPass]);
} else if (!err && row && row.count > 0) {
db.run(`DELETE FROM teams WHERE id = 'admin-0'`);
}
});
});
const unlinkFiles = (filesJson) => {
@@ -149,6 +191,14 @@ const unlinkFiles = (filesJson) => {
} catch (e) { console.error("Error unlinking files:", e); }
};
const SCHEMA_WHITELIST = {
teams: ['id', 'name', 'password', 'isAdmin', 'isDisabled'],
challenges: ['id', 'title', 'category', 'difficulty', 'description', 'initialPoints', 'minimumPoints', 'decaySolves', 'flag', 'files', 'port', 'connectionType', 'overrideIp'],
solves: ['teamId', 'challengeId', 'timestamp'],
blogs: ['id', 'title', 'content', 'timestamp'],
config: ['key', 'value']
};
app.use(cors());
app.use(express.json({ limit: '500mb' }));
app.use('/files', express.static(uploadDir));
@@ -157,7 +207,8 @@ const apiRouter = express.Router();
apiRouter.use('/admin', async (req, res, next) => {
const authHeader = req.headers.authorization;
const teamId = authHeader ? authHeader.replace('Bearer mock-token-', '') : null;
const token = authHeader ? authHeader.replace('Bearer ', '') : null;
const teamId = verifyToken(token);
if (!teamId) return res.status(401).json({ message: 'Unauthorized' });
try {
@@ -182,7 +233,7 @@ apiRouter.post('/auth/register', (req, res) => {
if (err.message.includes('UNIQUE')) return res.status(400).json({ message: 'Team name already exists' });
return res.status(500).json({ message: 'Registration failed' });
}
res.json({ team: { id, name, isAdmin: 0, isDisabled: 0 }, token: `mock-token-${id}` });
res.json({ team: { id, name, isAdmin: 0, isDisabled: 0 }, token: generateToken(id) });
});
});
@@ -191,8 +242,20 @@ apiRouter.post('/auth/login', (req, res) => {
db.get("SELECT * FROM teams WHERE name = ?", [name], (err, team) => {
if (err || !team || !comparePassword(password, team.password)) return res.status(401).json({ message: 'Invalid credentials' });
if (team.isDisabled) return res.status(403).json({ message: 'Account disabled' });
if (team.id === 'admin-0') {
db.get("SELECT COUNT(*) as count FROM teams WHERE isAdmin = 1 AND id != 'admin-0'", (err, row) => {
if (row && row.count > 0) {
return res.status(403).json({ message: 'Default admin account disabled' });
}
const { password: _, ...teamData } = team;
res.json({ team: teamData, token: generateToken(team.id) });
});
return;
}
const { password: _, ...teamData } = team;
res.json({ team: teamData, token: `mock-token-${team.id}` });
res.json({ team: teamData, token: generateToken(team.id) });
});
});
@@ -201,7 +264,8 @@ apiRouter.get('/state', async (req, res) => {
// Security Check: Identify requester and admin status
const authHeader = req.headers.authorization;
const teamId = authHeader ? authHeader.replace('Bearer mock-token-', '') : null;
const token = authHeader ? authHeader.replace('Bearer ', '') : null;
const teamId = verifyToken(token);
let isAdmin = false;
if (teamId) {
@@ -219,7 +283,17 @@ apiRouter.get('/state', async (req, res) => {
state.isStarted = state.config.isStarted === 'true';
db.all("SELECT id, name, isAdmin, isDisabled FROM teams", (err, teams) => {
if (err) return res.status(500).json({ error: 'Failed to fetch teams' });
state.teams = teamId ? (teams || []) : [];
if (isAdmin) {
state.teams = teams || [];
} else {
// For non-admins, only show active non-admin teams for the scoreboard
state.teams = (teams || []).filter(t => !t.isAdmin && !t.isDisabled).map(t => {
const { isAdmin: _, isDisabled: __, ...publicTeam } = t;
return publicTeam;
});
}
db.all("SELECT * FROM challenges", (err, challenges) => {
if (err) return res.status(500).json({ error: 'Failed to fetch challenges' });
db.all("SELECT * FROM solves", (err, solves) => {
@@ -314,12 +388,13 @@ apiRouter.post('/admin/db/restore', upload.single('restoreFile'), (req, res) =>
fs.unlinkSync(req.file.path);
db.serialize(() => {
db.run("BEGIN TRANSACTION");
const tables = ['teams', 'challenges', 'solves', 'blogs', 'config'];
tables.forEach(table => {
Object.keys(SCHEMA_WHITELIST).forEach(table => {
db.run(`DELETE FROM ${table}`);
if (!data[table] || data[table].length === 0) return;
const rows = data[table];
const cols = Object.keys(rows[0]);
const cols = Object.keys(rows[0]).filter(c => SCHEMA_WHITELIST[table].includes(c));
if (cols.length === 0) return;
const stmt = db.prepare(`INSERT INTO ${table} (${cols.join(',')}) VALUES (${cols.map(()=>'?').join(',')})`);
rows.forEach(row => {
if (table === 'challenges') {
@@ -333,7 +408,8 @@ apiRouter.post('/admin/db/restore', upload.single('restoreFile'), (req, res) =>
return file;
}));
}
stmt.run(Object.values(row));
const params = cols.map(c => row[c]);
stmt.run(params);
});
stmt.finalize();
});
@@ -344,7 +420,8 @@ apiRouter.post('/admin/db/restore', upload.single('restoreFile'), (req, res) =>
apiRouter.post('/challenges/submit', (req, res) => {
const authHeader = req.headers.authorization;
const teamId = authHeader ? authHeader.replace('Bearer mock-token-', '') : null;
const token = authHeader ? authHeader.replace('Bearer ', '') : null;
const teamId = verifyToken(token);
const { challengeId, flag } = req.body;
if (!teamId) return res.status(401).json({ success: false });
db.all("SELECT key, value FROM config", (err, configRows) => {
@@ -380,7 +457,9 @@ apiRouter.post('/admin/toggle-ctf', (req, res) => {
apiRouter.post('/admin/reset-scores', (req, res) => { db.run("DELETE FROM solves", () => res.json({ success: true })); });
apiRouter.put('/profile', (req, res) => {
const teamId = req.headers.authorization?.replace('Bearer mock-token-', '');
const authHeader = req.headers.authorization;
const token = authHeader ? authHeader.replace('Bearer ', '') : null;
const teamId = verifyToken(token);
if (!teamId) return res.status(401).json({ message: 'Unauthorized' });
db.run("UPDATE teams SET password = ? WHERE id = ?", [hashPassword(req.body.password), teamId], () => res.json({ success: true }));
});