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:
115
server.js
115
server.js
@@ -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 }));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user